From e496445b35d570b72f5e9d27f7e2f77e9c804c3b Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Mon, 2 Mar 2026 15:04:33 +0500 Subject: [PATCH] Persist per-scan meal details and fix order history state across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store meal/session label, menu items, total price, department, and department type on each ScanRecord in SQLite so every scan keeps its own metadata. Add lightweight schema upgrades for lunch_order_transactions to include MealLabel, MealItems, TotalPrice, Department, and DepartmentType. Update RfidService to populate these new fields and expose UpdateLastScanMealInfo for post-scan enrichment. Fix dashboard startup logic to restore last scanned employee (ID, name, department, department type, meal) from the last ScanRecord. Refactor order history and “View All” modal to read Meal/Items and Price from each row’s stored values instead of a global in-memory label. Include Meal and Price columns in the CSV export, using per-record data. Remove unused local MealSchedules/OrderItems SQLite tables and ensure they are dropped as legacy schema. --- App.xaml | 3 + App.xaml.cs | 7 +- Converters/MealSessionToNameConverter.cs | 23 +++ Data/AppDbContext.cs | 40 +++- Models/HrmsMenuItem.cs | 4 + Models/MealSchedule.cs | 20 ++ Models/MealSession.cs | 15 ++ Models/OrderHistoryItem.cs | 6 +- Models/ScanRecord.cs | 10 + Services/DbMealSessionResolver.cs | 71 +++++++ Services/EmployeeLookupService.cs | 1 + Services/IMealScheduleService.cs | 24 +++ Services/IMealSessionResolver.cs | 14 ++ Services/INavigationService.cs | 2 + Services/IRfidService.cs | 6 + Services/MenuLookupService.cs | 15 +- Services/NavigationService.cs | 15 +- Services/ProductionMealScheduleService.cs | 207 +++++++++++++++++++ Services/RfidService.cs | 39 +++- Services/ScanResult.cs | 6 +- ViewModels/MealSchedulesViewModel.cs | 232 ++++++++++++++++++++++ ViewModels/ScannerDashboardViewModel.cs | 136 +++++++++++-- ViewModels/SettingsViewModel.cs | 7 + Views/MealSchedulesView.xaml | 117 +++++++++++ Views/MealSchedulesView.xaml.cs | 12 ++ Views/ScannerDashboardView.xaml | 19 +- Views/SettingsView.xaml | 5 + 27 files changed, 1016 insertions(+), 40 deletions(-) create mode 100644 Converters/MealSessionToNameConverter.cs create mode 100644 Models/MealSchedule.cs create mode 100644 Models/MealSession.cs create mode 100644 Services/DbMealSessionResolver.cs create mode 100644 Services/IMealScheduleService.cs create mode 100644 Services/IMealSessionResolver.cs create mode 100644 Services/ProductionMealScheduleService.cs create mode 100644 ViewModels/MealSchedulesViewModel.cs create mode 100644 Views/MealSchedulesView.xaml create mode 100644 Views/MealSchedulesView.xaml.cs diff --git a/App.xaml b/App.xaml index d338808..920b2fc 100644 --- a/App.xaml +++ b/App.xaml @@ -20,6 +20,9 @@ + + + diff --git a/App.xaml.cs b/App.xaml.cs index f8bc707..55142af 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -41,7 +41,9 @@ public partial class App : Application var configService = new ConfigService(); var employeeLookupService = new EmployeeLookupService(configService); var menuLookupService = new MenuLookupService(configService); - var rfidService = new RfidService(dbFactory, configService, employeeLookupService); + var mealScheduleService = new ProductionMealScheduleService(configService); + var mealSessionResolver = new DbMealSessionResolver(mealScheduleService); + var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver); var syncService = new SyncService(dbFactory, configService); var adminAuditService = new AdminAuditService(dbFactory); var session = new AppSession(); @@ -56,7 +58,8 @@ public partial class App : Application () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService), () => new MainDashboardViewModel(navigationService, rfidService, configService, session), () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService), - () => new SettingsViewModel(configService, navigationService, adminAuditService, syncService)); + () => new SettingsViewModel(configService, navigationService, adminAuditService, syncService), + () => new MealSchedulesViewModel(mealScheduleService, navigationService)); var mainViewModel = new MainViewModel(navigationService); diff --git a/Converters/MealSessionToNameConverter.cs b/Converters/MealSessionToNameConverter.cs new file mode 100644 index 0000000..4b1b6d3 --- /dev/null +++ b/Converters/MealSessionToNameConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace UtopiaCanteenSystem.Converters; + +/// Converts MealSession int (0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner) to display name. +public class MealSessionToNameConverter : IValueConverter +{ + private static readonly string[] Names = { "Breakfast", "Lunch", "Tea", "Dinner" }; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is int i && i >= 0 && i < Names.Length) + return Names[i]; + return value?.ToString() ?? ""; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 2adb2b0..fb50f4c 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -176,6 +176,41 @@ public class AppDbContext : DbContext cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN EmployeeName TEXT DEFAULT ''"; cmd.ExecuteNonQuery(); } + + if (columns.Count > 0 && !columns.Contains("Department", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN Department TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("DepartmentType", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN DepartmentType TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("MealLabel", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN MealLabel TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("MealItems", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN MealItems TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("TotalPrice", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN TotalPrice REAL DEFAULT 0"; + cmd.ExecuteNonQuery(); + } } catch { @@ -228,7 +263,10 @@ public class AppDbContext : DbContext "MealRates", // Support both spellings of the temporary meal plan overrides table. "TemporaryMealPlanOverirdes", - "TemporaryMealPlanOverrides" + "TemporaryMealPlanOverrides", + // Old local-only tables we no longer use; all meal timings now come from production HRMS. + "MealSchedules", + "OrderItems" }; foreach (var table in legacyTables) diff --git a/Models/HrmsMenuItem.cs b/Models/HrmsMenuItem.cs index 8028445..2498e17 100644 --- a/Models/HrmsMenuItem.cs +++ b/Models/HrmsMenuItem.cs @@ -5,6 +5,10 @@ namespace UtopiaCanteenSystem.Models; /// public class HrmsMenuItem { + /// menu_item.id + public int MenuItemId { get; set; } public string ItemName { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; + /// menu_item.price (HRMS). Decimal is safest for money. + public decimal Price { get; set; } } diff --git a/Models/MealSchedule.cs b/Models/MealSchedule.cs new file mode 100644 index 0000000..8ffc7c5 --- /dev/null +++ b/Models/MealSchedule.cs @@ -0,0 +1,20 @@ +namespace UtopiaCanteenSystem.Models; + +/// +/// Meal timing window from production hrms.meal_schedule. id is bigint; location_site_id displayed as normalized string (e.g. "02"). +/// +public class MealSchedule +{ + public long Id { get; set; } + /// Site identifier (matches ScanRecord.SiteId / config). + public string LocationSiteId { get; set; } = string.Empty; + /// Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value). + public int MealSession { get; set; } + /// Window start time, stored as "HH:mm:ss". + public string StartTime { get; set; } = string.Empty; + /// Window end time, stored as "HH:mm:ss". + public string EndTime { get; set; } = string.Empty; + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Models/MealSession.cs b/Models/MealSession.cs new file mode 100644 index 0000000..26f9676 --- /dev/null +++ b/Models/MealSession.cs @@ -0,0 +1,15 @@ +namespace UtopiaCanteenSystem.Models; + +/// +/// Meal session identifier. Stored as int in MealSchedules table. +/// None = no matching schedule window (scan rejected). +/// +public enum MealSession +{ + /// No active schedule window; scan outside valid timings. + None = -1, + Breakfast = 0, + Lunch = 1, + Tea = 2, + Dinner = 3 +} diff --git a/Models/OrderHistoryItem.cs b/Models/OrderHistoryItem.cs index 12e5e5e..551925a 100644 --- a/Models/OrderHistoryItem.cs +++ b/Models/OrderHistoryItem.cs @@ -10,8 +10,10 @@ public class OrderHistoryItem public string Department { get; set; } = string.Empty; public string ScanId { get; set; } = string.Empty; public DateTime OrderTimeUtc { get; set; } - /// The ordered item name (Sehri/Iftari). - public string OrderItem { get; set; } = "Sehri/Iftari"; + /// The meal/menu label to display (e.g. Breakfast or "Biryani + Nihari"). + public string OrderItem { get; set; } = string.Empty; + /// Total price for this row, based on the active meal's menu items. + public double TotalPrice { get; set; } /// Display label: "Today", "Yesterday", or short date. public string RelativeDateLabel { get; set; } = string.Empty; /// Time only, e.g. "09:54 AM". diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs index a6d71c2..c33030b 100644 --- a/Models/ScanRecord.cs +++ b/Models/ScanRecord.cs @@ -22,4 +22,14 @@ public class ScanRecord public string EmployeeId { get; set; } = string.Empty; /// Full name (first + middle) from HRMS at scan time, when lookup succeeded. public string EmployeeName { get; set; } = string.Empty; + /// HRMS department title at scan time. + public string Department { get; set; } = string.Empty; + /// HRMS department type at scan time. + public string DepartmentType { get; set; } = string.Empty; + /// Meal/session label at scan time (e.g. Breakfast/Lunch/Tea/Dinner). + public string MealLabel { get; set; } = string.Empty; + /// Menu items string for this scan's meal (e.g. "Biryani + Nihari"). + public string MealItems { get; set; } = string.Empty; + /// Total price for this scan's meal menu. + public double TotalPrice { get; set; } } diff --git a/Services/DbMealSessionResolver.cs b/Services/DbMealSessionResolver.cs new file mode 100644 index 0000000..d6ab8d1 --- /dev/null +++ b/Services/DbMealSessionResolver.cs @@ -0,0 +1,71 @@ +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Resolves current meal session from production (MySQL meal_schedule). Caches schedules per site for 60 seconds. +/// No SQLite; scan validation uses production data only. +/// +public class DbMealSessionResolver : IMealSessionResolver +{ + private readonly IMealScheduleService _mealScheduleService; + private readonly TimeSpan _cacheTtl = TimeSpan.FromSeconds(60); + + private readonly Dictionary Schedules, DateTime ExpiryUtc)> _cache = new(StringComparer.OrdinalIgnoreCase); + private readonly object _cacheLock = new(); + + public DbMealSessionResolver(IMealScheduleService mealScheduleService) + { + _mealScheduleService = mealScheduleService; + } + + /// + public MealSession GetCurrentSession(DateTime nowLocal, string siteId) + { + var normalizedSite = (siteId ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(normalizedSite)) + normalizedSite = "01"; + if (normalizedSite.Length == 1 && char.IsDigit(normalizedSite[0])) + normalizedSite = normalizedSite.PadLeft(2, '0'); + + var schedules = GetSchedulesForSiteCached(normalizedSite); + var t = nowLocal.TimeOfDay; + + foreach (var s in schedules) + { + if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end)) + continue; + if (t >= start && t < end) + return (MealSession)s.MealSession; + } + + return MealSession.None; + } + + private List GetSchedulesForSiteCached(string siteId) + { + lock (_cacheLock) + { + if (_cache.TryGetValue(siteId, out var entry) && DateTime.UtcNow < entry.ExpiryUtc) + return entry.Schedules; + } + + var list = _mealScheduleService.GetActiveSchedulesForSite(siteId).ToList(); + + lock (_cacheLock) + { + _cache[siteId] = (list, DateTime.UtcNow.Add(_cacheTtl)); + } + + return list; + } + + private static bool TryParseTime(string value, out TimeSpan time) + { + time = TimeSpan.Zero; + if (string.IsNullOrWhiteSpace(value)) + return false; + return TimeSpan.TryParse(value.Trim(), null, out time) + || TimeSpan.TryParse(value.Trim().Replace(".", ":"), null, out time); + } +} diff --git a/Services/EmployeeLookupService.cs b/Services/EmployeeLookupService.cs index eefd59c..4b613f0 100644 --- a/Services/EmployeeLookupService.cs +++ b/Services/EmployeeLookupService.cs @@ -18,6 +18,7 @@ public class EmployeeLookupService : IEmployeeLookupService /// public async Task GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default) + { var connectionString = _configService.GetHrmsLookupConnectionString(); if (string.IsNullOrWhiteSpace(connectionString)) diff --git a/Services/IMealScheduleService.cs b/Services/IMealScheduleService.cs new file mode 100644 index 0000000..cfb8cf4 --- /dev/null +++ b/Services/IMealScheduleService.cs @@ -0,0 +1,24 @@ +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Reads and writes meal schedules from production (MySQL/POD). No SQLite; sync job only syncs scan data. +/// +public interface IMealScheduleService +{ + /// Active schedules for a site (used by resolver for scan validation). Returns empty if production not configured or error. + IReadOnlyList GetActiveSchedulesForSite(string siteId); + + /// All schedules for admin list. Returns empty if production not configured or error. + Task> GetAllSchedulesAsync(CancellationToken cancellationToken = default); + + /// Insert new schedule in production. Returns new id (bigint). + Task CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default); + + /// Update existing schedule in production. + Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default); + + /// Delete schedule in production by id (bigint). + Task DeleteAsync(long id, CancellationToken cancellationToken = default); +} diff --git a/Services/IMealSessionResolver.cs b/Services/IMealSessionResolver.cs new file mode 100644 index 0000000..3e54289 --- /dev/null +++ b/Services/IMealSessionResolver.cs @@ -0,0 +1,14 @@ +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Resolves current meal session from DB-driven schedule (Phase 2.5). Replaces hardcoded time windows. +/// +public interface IMealSessionResolver +{ + /// + /// Returns the active meal session for the given local time and site, or MealSession.None if outside all windows. + /// + MealSession GetCurrentSession(DateTime nowLocal, string siteId); +} diff --git a/Services/INavigationService.cs b/Services/INavigationService.cs index 3fe8a58..dd4ff20 100644 --- a/Services/INavigationService.cs +++ b/Services/INavigationService.cs @@ -16,6 +16,8 @@ public interface INavigationService void NavigateToDashboard(); void NavigateToAdminSettingsAuth(); void NavigateToSettings(); + /// Opens Meal Schedules admin screen (Phase 2.5). + void NavigateToMealSchedules(); /// Starts a new dashboard session (used after scan). void StartDashboardSession(); diff --git a/Services/IRfidService.cs b/Services/IRfidService.cs index 73a80ea..1049dee 100644 --- a/Services/IRfidService.cs +++ b/Services/IRfidService.cs @@ -50,4 +50,10 @@ public interface IRfidService /// Returns number of scans recorded today (local day) for a given card ID. /// Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default); + + /// + /// Updates the most recent scan record with resolved meal label, menu items and total price. + /// Used so that history rows keep their own Meal/Price, even after app restart. + /// + void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice); } diff --git a/Services/MenuLookupService.cs b/Services/MenuLookupService.cs index 1b647a4..20f8c20 100644 --- a/Services/MenuLookupService.cs +++ b/Services/MenuLookupService.cs @@ -24,7 +24,7 @@ public class MenuLookupService : IMenuLookupService return Array.Empty(); const string sql = @" - SELECT DISTINCT mi.item_name, mi.item_type + SELECT DISTINCT mi.id, mi.item_name, mi.item_type, mi.price FROM lunch_menu_week w JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id JOIN menu_item mi ON mi.id = li.menu_item_id @@ -43,8 +43,10 @@ public class MenuLookupService : IMenuLookupService { list.Add(new HrmsMenuItem { - ItemName = GetString(reader, 0), - ItemType = GetString(reader, 1) + MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + ItemName = GetString(reader, 1), + ItemType = GetString(reader, 2), + Price = GetDecimal(reader, 3) }); } @@ -57,4 +59,11 @@ public class MenuLookupService : IMenuLookupService var v = reader.GetValue(ordinal); return v?.ToString() ?? string.Empty; } + + private static decimal GetDecimal(MySqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) return 0m; + var v = reader.GetValue(ordinal); + return v is decimal d ? d : Convert.ToDecimal(v); + } } diff --git a/Services/NavigationService.cs b/Services/NavigationService.cs index ebd43a6..3f126fc 100644 --- a/Services/NavigationService.cs +++ b/Services/NavigationService.cs @@ -29,6 +29,7 @@ public class NavigationService : INavigationService private readonly Func _dashboardVm; private readonly Func _adminSettingsAuthVm; private readonly Func _settingsVm; + private readonly Func _mealSchedulesVm; public NavigationService( AppSession session, @@ -36,7 +37,8 @@ public class NavigationService : INavigationService Func scannerVm, Func dashboardVm, Func adminSettingsAuthVm, - Func settingsVm) + Func settingsVm, + Func mealSchedulesVm) { _session = session; _adminLoginVm = adminLoginVm; @@ -44,6 +46,7 @@ public class NavigationService : INavigationService _dashboardVm = dashboardVm; _adminSettingsAuthVm = adminSettingsAuthVm; _settingsVm = settingsVm; + _mealSchedulesVm = mealSchedulesVm; } public void NavigateToAdminLogin() @@ -95,6 +98,16 @@ public class NavigationService : INavigationService CurrentViewModel = _adminSettingsAuthVm(); } + public void NavigateToMealSchedules() + { + if (!_session.IsAdminAuthenticated) + { + NavigateToAdminLogin(); + return; + } + CurrentViewModel = _mealSchedulesVm(); + } + public void StartDashboardSession() { _dashboardSessionStartUtc = DateTime.UtcNow; diff --git a/Services/ProductionMealScheduleService.cs b/Services/ProductionMealScheduleService.cs new file mode 100644 index 0000000..5ee582f --- /dev/null +++ b/Services/ProductionMealScheduleService.cs @@ -0,0 +1,207 @@ +using MySqlConnector; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Meal schedule CRUD against production HRMS MySQL table hrms.meal_schedule. +/// Uses IConfigService.GetHrmsLookupConnectionString() (same DB as employee lookup). +/// Schema: id (bigint), meal_name (varchar), start_time (time), end_time (time), created_at, updated_at, location_site_id (int). +/// No is_active column; all rows are treated as active. +/// +public class ProductionMealScheduleService : IMealScheduleService +{ + private readonly IConfigService _configService; + private const string TableRef = "`hrms`.`meal_schedule`"; + + public ProductionMealScheduleService(IConfigService configService) + { + _configService = configService; + } + + /// + public IReadOnlyList GetActiveSchedulesForSite(string siteId) + { + var connStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + return Array.Empty(); + + var siteIdInt = SiteIdStringToInt(siteId); + var list = new List(); + try + { + using var conn = new MySqlConnection(connStr); + conn.Open(); + var sql = $"SELECT id, meal_name, start_time, end_time, created_at, updated_at, location_site_id FROM {TableRef} WHERE location_site_id = @siteId ORDER BY start_time"; + using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@siteId", siteIdInt); + using var r = cmd.ExecuteReader(); + while (r.Read()) + list.Add(ReadRow(r)); + } + catch + { + return Array.Empty(); + } + return list; + } + + /// + public async Task> GetAllSchedulesAsync(CancellationToken cancellationToken = default) + { + var connStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + return Array.Empty(); + + var list = new List(); + await using var conn = new MySqlConnection(connStr); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + var sql = $"SELECT id, meal_name, start_time, end_time, created_at, updated_at, location_site_id FROM {TableRef} ORDER BY location_site_id, start_time"; + await using var cmd = new MySqlCommand(sql, conn); + await using var r = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await r.ReadAsync(cancellationToken).ConfigureAwait(false)) + list.Add(ReadRow(r)); + return list; + } + + /// + public async Task CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) + { + var connStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + throw new InvalidOperationException("HRMS MySQL connection string not configured."); + + var utc = DateTime.UtcNow; + var mealName = MealSessionToName(schedule.MealSession); + var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId); + + await using var conn = new MySqlConnection(connStr); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"INSERT INTO {TableRef} (meal_name, start_time, end_time, created_at, updated_at, location_site_id) + VALUES (@mealName, @startTime, @endTime, @createdAt, @updatedAt, @siteId)"; + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@mealName", mealName); + cmd.Parameters.AddWithValue("@startTime", schedule.StartTime?.Trim() ?? "00:00:00"); + cmd.Parameters.AddWithValue("@endTime", schedule.EndTime?.Trim() ?? "23:59:59"); + cmd.Parameters.AddWithValue("@createdAt", utc); + cmd.Parameters.AddWithValue("@updatedAt", utc); + cmd.Parameters.AddWithValue("@siteId", siteIdInt); + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return cmd.LastInsertedId; + } + + /// + public async Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) + { + var connStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + throw new InvalidOperationException("HRMS MySQL connection string not configured."); + + var utc = DateTime.UtcNow; + var mealName = MealSessionToName(schedule.MealSession); + var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId); + + await using var conn = new MySqlConnection(connStr); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"UPDATE {TableRef} SET meal_name = @mealName, start_time = @startTime, end_time = @endTime, location_site_id = @siteId, updated_at = @updatedAt WHERE id = @id"; + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@id", schedule.Id); + cmd.Parameters.AddWithValue("@mealName", mealName); + cmd.Parameters.AddWithValue("@startTime", schedule.StartTime?.Trim() ?? "00:00:00"); + cmd.Parameters.AddWithValue("@endTime", schedule.EndTime?.Trim() ?? "23:59:59"); + cmd.Parameters.AddWithValue("@siteId", siteIdInt); + cmd.Parameters.AddWithValue("@updatedAt", utc); + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + var connStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + throw new InvalidOperationException("HRMS MySQL connection string not configured."); + + await using var conn = new MySqlConnection(connStr); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + var sql = $"DELETE FROM {TableRef} WHERE id = @id"; + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// Read row: id (bigint), meal_name, start_time (time), end_time (time), created_at, updated_at, location_site_id (int). + private static MealSchedule ReadRow(MySqlDataReader r) + { + return new MealSchedule + { + Id = r.GetInt64(0), + MealSession = MealNameToSession(GetString(r, 1)), + StartTime = GetTimeString(r, 2), + EndTime = GetTimeString(r, 3), + CreatedAt = GetDateTime(r, 4), + UpdatedAt = GetDateTime(r, 5), + LocationSiteId = IntToSiteIdString(r.IsDBNull(6) ? 0 : r.GetInt32(6)), + IsActive = true + }; + } + + private static string GetString(MySqlDataReader r, int i) + { + if (r.IsDBNull(i)) return ""; + return r.GetString(i) ?? ""; + } + + private static string GetTimeString(MySqlDataReader r, int i) + { + if (r.IsDBNull(i)) return "00:00:00"; + var v = r.GetValue(i); + if (v is TimeSpan ts) + return ts.ToString(@"hh\:mm\:ss"); + return v?.ToString()?.Trim() ?? "00:00:00"; + } + + private static DateTime GetDateTime(MySqlDataReader r, int i) + { + if (r.IsDBNull(i)) return DateTime.UtcNow; + var dt = r.GetDateTime(i); + return dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Utc); + } + + private static int MealNameToSession(string mealName) + { + if (string.IsNullOrWhiteSpace(mealName)) return 0; + var n = mealName.Trim(); + if (n.Equals("Breakfast", StringComparison.OrdinalIgnoreCase) || n.Equals("BreakFast", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Breakfast; + if (n.Equals("Lunch", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Lunch; + if (n.Equals("Tea", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Tea; + if (n.Equals("Dinner", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Dinner; + return 0; + } + + private static string MealSessionToName(int mealSession) + { + return mealSession switch + { + (int)MealSession.Breakfast => "Breakfast", + (int)MealSession.Lunch => "Lunch", + (int)MealSession.Tea => "Tea", + (int)MealSession.Dinner => "Dinner", + _ => "Breakfast" + }; + } + + private static int SiteIdStringToInt(string siteId) + { + var s = (siteId ?? "").Trim(); + if (string.IsNullOrEmpty(s)) return 0; + var digits = new string(s.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var n) ? n : 0; + } + + /// Normalize production location_site_id int to display string (e.g. 2 -> "02"). + private static string IntToSiteIdString(int siteIdInt) + { + if (siteIdInt <= 0) return "01"; + return siteIdInt <= 99 ? siteIdInt.ToString("D2") : siteIdInt.ToString(); + } +} diff --git a/Services/RfidService.cs b/Services/RfidService.cs index d212e27..276f377 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -19,12 +19,14 @@ public class RfidService : IRfidService private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; private readonly IEmployeeLookupService _employeeLookup; + private readonly IMealSessionResolver _mealSessionResolver; - public RfidService(IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup) + public RfidService(IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver) { _dbFactory = dbFactory; _configService = configService; _employeeLookup = employeeLookup; + _mealSessionResolver = mealSessionResolver; } public (bool Success, string Message) ProcessScan(string cardId) @@ -46,6 +48,15 @@ public class RfidService : IRfidService return new ScanResult(false, "Card not registered in HRMS.", 0); var nowUtc = DateTime.UtcNow; + var nowLocal = DateTime.Now; + + // Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session. + var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) + ? employee.LocationSiteId.Trim() + : _configService.GetSiteId(); + var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); + if (session == MealSession.None) + return new ScanResult(false, "This scan is outside of valid meal timings.", 0); using var db = _dbFactory.CreateDbContext(); @@ -86,21 +97,26 @@ public class RfidService : IRfidService } var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) + ? employee.LocationSiteId.Trim() + : _configService.GetSiteId(); var record = new ScanRecord { CardId = cardId, ScanTime = nowUtc, IsSynced = false, - SiteId = _configService.GetSiteId(), + SiteId = siteId, DeviceId = _configService.GetDeviceId(), IpAddress = GetLocalIpAddress(), EmployeeId = employee.EmployeeId ?? string.Empty, - EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName + EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName, + Department = employee.DepartmentTitle ?? string.Empty, + DepartmentType = employee.DepartmentType ?? string.Empty }; db.LunchOrderTransactions.Add(record); db.SaveChanges(); - return new ScanResult(true, "Order recorded successfully.", 0, employee); + return new ScanResult(true, "Order recorded successfully.", 0, employee, session); } public ScanRecord? GetLastScan() @@ -202,6 +218,21 @@ public class RfidService : IRfidService .ConfigureAwait(false); } + public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice) + { + using var db = _dbFactory.CreateDbContext(); + var last = db.LunchOrderTransactions + .OrderByDescending(r => r.ScanTime) + .FirstOrDefault(); + if (last == null) + return; + + last.MealLabel = mealLabel ?? string.Empty; + last.MealItems = mealItems ?? string.Empty; + last.TotalPrice = totalPrice; + db.SaveChanges(); + } + private static string FormatTimeout(int seconds) { if (seconds <= 0) diff --git a/Services/ScanResult.cs b/Services/ScanResult.cs index b8f0789..809d9d9 100644 --- a/Services/ScanResult.cs +++ b/Services/ScanResult.cs @@ -4,11 +4,13 @@ namespace UtopiaCanteenSystem.Services; /// /// Result of processing a scan, including an optional cooldown (in seconds) when blocked. -/// When Success is true, EmployeeInfo may contain the HRMS employee data for display. +/// When Success is true, EmployeeInfo may contain the HRMS employee data for display, and MealSession +/// indicates which meal window was active according to production hrms.meal_schedule. /// public readonly record struct ScanResult( bool Success, string Message, int CooldownSecondsRemaining, - HrmsEmployeeInfo? EmployeeInfo = null); + HrmsEmployeeInfo? EmployeeInfo = null, + MealSession MealSession = MealSession.None); diff --git a/ViewModels/MealSchedulesViewModel.cs b/ViewModels/MealSchedulesViewModel.cs new file mode 100644 index 0000000..6f56c0b --- /dev/null +++ b/ViewModels/MealSchedulesViewModel.cs @@ -0,0 +1,232 @@ +using System.Collections.ObjectModel; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MySqlConnector; +using UtopiaCanteenSystem.Models; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +/// +/// Admin CRUD for meal schedules in production (hrms.meal_schedule). No SQLite. Load all on open; optional Site filter (local). +/// +public partial class MealSchedulesViewModel : ObservableObject +{ + private readonly IMealScheduleService _mealScheduleService; + private readonly INavigationService _navigation; + + [ObservableProperty] + private ObservableCollection _schedules = new(); + + /// Full list from production; Schedules is filtered by SelectedSiteFilter. + private List _allSchedules = new(); + + [ObservableProperty] + private ObservableCollection _siteFilterChoices = new() { "All" }; + + [ObservableProperty] + private string _selectedSiteFilter = "All"; + + [ObservableProperty] + private MealSchedule? _selectedSchedule; + + [ObservableProperty] + private string _locationSiteId = string.Empty; + + [ObservableProperty] + private int _mealSessionIndex; // 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner + + [ObservableProperty] + private string _startTime = "06:00:00"; + + [ObservableProperty] + private string _endTime = "09:00:00"; + + [ObservableProperty] + private string _message = string.Empty; + + [ObservableProperty] + private bool _isError; + + [ObservableProperty] + private bool _isLoading; + + public static readonly string[] MealSessionNames = { "Breakfast", "Lunch", "Tea", "Dinner" }; + + public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation) + { + _mealScheduleService = mealScheduleService; + _navigation = navigation; + _ = LoadSchedulesAsync(); + } + + partial void OnSelectedSiteFilterChanged(string value) + { + ApplyFilter(); + } + + partial void OnSelectedScheduleChanged(MealSchedule? value) + { + if (value == null) return; + LocationSiteId = value.LocationSiteId ?? string.Empty; + MealSessionIndex = value.MealSession; + StartTime = value.StartTime ?? "00:00:00"; + EndTime = value.EndTime ?? "23:59:59"; + } + + private void ApplyFilter() + { + if (string.IsNullOrEmpty(SelectedSiteFilter) || SelectedSiteFilter == "All") + { + Schedules = new ObservableCollection(_allSchedules); + return; + } + var filtered = _allSchedules.Where(s => string.Equals(s.LocationSiteId?.Trim(), SelectedSiteFilter.Trim(), StringComparison.OrdinalIgnoreCase)).ToList(); + Schedules = new ObservableCollection(filtered); + } + + private async Task LoadSchedulesAsync() + { + IsLoading = true; + Message = string.Empty; + IsError = false; + try + { + var list = await _mealScheduleService.GetAllSchedulesAsync().ConfigureAwait(true); + _allSchedules = list.ToList(); + var siteIds = _allSchedules.Select(s => s.LocationSiteId?.Trim() ?? "").Where(s => !string.IsNullOrEmpty(s)).Distinct().OrderBy(s => s, StringComparer.Ordinal).ToList(); + SiteFilterChoices = new ObservableCollection(new[] { "All" }.Concat(siteIds)); + SelectedSiteFilter = "All"; + ApplyFilter(); + if (_allSchedules.Count == 0) + Message = "No schedules in production. Add one below (HRMS MySQL must be configured)."; + } + catch (Exception ex) + { + IsError = true; + var msg = ex is MySqlException mysql + ? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}" + : $"{ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}"; + Message = "Could not load schedules from production."; + MessageBox.Show(msg, "Meal Schedules – Load Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private void AddNew() + { + SelectedSchedule = null; + LocationSiteId = "02"; + MealSessionIndex = 0; + StartTime = "06:00:00"; + EndTime = "09:00:00"; + Message = string.Empty; + } + + [RelayCommand] + private async Task Save() + { + Message = string.Empty; + IsError = false; + + if (!TimeSpan.TryParse(StartTime?.Trim(), out var start) || !TimeSpan.TryParse(EndTime?.Trim(), out var end)) + { + Message = "Start time and end time must be in HH:mm:ss format."; + IsError = true; + return; + } + if (start >= end) + { + Message = "Start time must be before end time."; + IsError = true; + return; + } + + var siteId = (LocationSiteId ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(siteId)) + { + Message = "Location site ID is required."; + IsError = true; + return; + } + + try + { + if (SelectedSchedule != null) + { + var dto = new MealSchedule + { + Id = SelectedSchedule.Id, + LocationSiteId = siteId, + MealSession = MealSessionIndex, + StartTime = StartTime.Trim(), + EndTime = EndTime.Trim() + }; + await _mealScheduleService.UpdateAsync(dto).ConfigureAwait(true); + Message = "Schedule updated in production."; + } + else + { + var dto = new MealSchedule + { + LocationSiteId = siteId, + MealSession = MealSessionIndex, + StartTime = StartTime.Trim(), + EndTime = EndTime.Trim() + }; + await _mealScheduleService.CreateAsync(dto).ConfigureAwait(true); + Message = "Schedule added to production."; + } + await LoadSchedulesAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + IsError = true; + var msg = ex is MySqlException mysql + ? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}" + : $"{ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}"; + Message = "Failed to save to production."; + MessageBox.Show(msg, "Meal Schedules – Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + [RelayCommand] + private async Task Delete() + { + if (SelectedSchedule == null) + { + Message = "Select a schedule to delete."; + IsError = true; + return; + } + Message = string.Empty; + IsError = false; + try + { + await _mealScheduleService.DeleteAsync(SelectedSchedule.Id).ConfigureAwait(true); + Message = "Schedule deleted from production."; + SelectedSchedule = null; + await LoadSchedulesAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + IsError = true; + var msg = ex is MySqlException mysql + ? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}" + : $"{ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}"; + Message = "Failed to delete."; + MessageBox.Show(msg, "Meal Schedules – Delete Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + [RelayCommand] + private void Back() + { + _navigation.NavigateToSettings(); + } +} diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index 163e4e2..43cfb69 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -121,13 +121,19 @@ public partial class ScannerDashboardViewModel : ObservableObject [ObservableProperty] private string _employeeDepartmentType = "—"; - /// Meal(s) for the order from HRMS menu (e.g. "Sehri / Iftari"). Fetched by site. + /// Meal label for the current scan (e.g. Breakfast / Lunch / Tea / Dinner) from hrms.meal_schedule. [ObservableProperty] private string _employeeOrderItem = "Sehri/Iftari"; - /// Current site meal display for order history rows (from HRMS menu). + /// + /// Current menu display for order history rows: concatenated item_name values for the active meal/session + /// (e.g. "Biryani + Nihari"), derived from HRMS menu_item.item_type for the resolved meal. + /// private string _siteMealDisplay = "Sehri/Iftari"; + /// Total price for the current meal's menu (sum of matching items' prices). + private double _siteMealTotalPrice; + /// Optional profile image path; null = show placeholder. [ObservableProperty] private string? _employeeProfileImagePath; @@ -208,12 +214,17 @@ public partial class ScannerDashboardViewModel : ObservableObject RefreshScannerStatus(); _ = RefreshDashboardAsync(); + // On startup (no recent scan), there may be no active meal session; show generic label. _ = LoadMenuForSiteAsync(); } - /// Loads meal(s) from HRMS for the given site and updates Order display and order history label. - /// When siteIdFromScan is set, uses that (from employee_rfid_tag.location_site_id); otherwise falls back to config SiteNumber. - private async Task LoadMenuForSiteAsync(int? siteIdFromScan = null) + /// + /// Loads meal(s) from HRMS for the given site and updates Order display and order history label. + /// When siteIdFromScan is set, uses that (from employee_rfid_tag.location_site_id); otherwise falls back to config SiteNumber. + /// Meal/session comes from production hrms.meal_schedule via ScanResult (sessionFromScan); when no session is provided, + /// we show a generic \"No active meal session\" label. + /// + private async Task LoadMenuForSiteAsync(int? siteIdFromScan = null, MealSession? sessionFromScan = null) { int siteIdNumeric; if (siteIdFromScan.HasValue) @@ -222,15 +233,73 @@ public partial class ScannerDashboardViewModel : ObservableObject return; try { + // Fetch all menu items for this site (current week only) from HRMS. var items = await _menuLookupService.GetMenuItemsForSiteAsync(siteIdNumeric).ConfigureAwait(false); - var display = items.Count > 0 - ? string.Join(" / ", items.Select(i => i.ItemName).Where(s => !string.IsNullOrWhiteSpace(s))) - : "Sehri/Iftari"; - if (string.IsNullOrWhiteSpace(display)) display = "Sehri/Iftari"; - _siteMealDisplay = display; + + var activeSession = sessionFromScan ?? MealSession.None; + + // 1) Determine the meal label from hrms.meal_schedule (Breakfast / Lunch / Tea / Dinner). + string mealLabel; + if (activeSession == MealSession.None) + { + mealLabel = "No active meal session"; + } + else + { + mealLabel = activeSession switch + { + MealSession.Breakfast => "Breakfast", + MealSession.Lunch => "Lunch", + MealSession.Tea => "Tea", + MealSession.Dinner => "Dinner", + _ => "No active meal session" + }; + } + + // 2) Build the "correct menu" string for this session: item_name(s) whose item_type + // contains the current meal name (Breakfast/Lunch/Tea/Dinner). + string historyDisplay = mealLabel; + double totalPrice = 0; + if (!string.IsNullOrWhiteSpace(mealLabel) && + !string.Equals(mealLabel, "No active meal session", StringComparison.OrdinalIgnoreCase) && + items.Count > 0) + { + var matchingItems = items + .Where(i => + !string.IsNullOrWhiteSpace(i.ItemType) && + i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + + var matchingItemNames = matchingItems + .Select(i => i.ItemName) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct() + .ToList(); + + if (matchingItemNames.Count > 0) + { + // Example: "Iftari", "Sehri", "Biryani + Nihari" + historyDisplay = string.Join(" + ", matchingItemNames); + // Sum prices of all matching items for this meal/session. + totalPrice = matchingItems.Sum(i => (double)i.Price); + } + } + + // Left panel shows the meal label (session from meal_schedule), + // Order History "Meal" column shows the menu item_name string for that session, + // and "Price" column shows the summed price of those items. + _siteMealDisplay = historyDisplay; + _siteMealTotalPrice = totalPrice; + + // Persist these values on the latest scan record so history rows keep their own Meal/Price. + // Only do this when we are handling an actual scan (sessionFromScan has a value). + if (sessionFromScan.HasValue && activeSession != MealSession.None) + { + _rfidService.UpdateLastScanMealInfo(mealLabel, historyDisplay, totalPrice); + } _uiDispatcher.Invoke(() => { - EmployeeOrderItem = display; + EmployeeOrderItem = mealLabel; LoadOrderHistory(); if (IsOrderHistoryModalOpen) LoadTodayOrderHistory(); @@ -238,10 +307,11 @@ public partial class ScannerDashboardViewModel : ObservableObject } catch { - _siteMealDisplay = "Sehri/Iftari"; + _siteMealDisplay = "No active meal session"; + _siteMealTotalPrice = 0; _uiDispatcher.Invoke(() => { - EmployeeOrderItem = "Sehri/Iftari"; + EmployeeOrderItem = "No active meal session"; LoadOrderHistory(); if (IsOrderHistoryModalOpen) LoadTodayOrderHistory(); @@ -270,7 +340,7 @@ public partial class ScannerDashboardViewModel : ObservableObject // Persist to config when valid (non-empty numeric). Menu still loads from RFID site on next scan. if (!string.IsNullOrWhiteSpace(digits)) _configService.SetSiteId("SITE : " + digits); - // Reload menu using config site (fallback when no scan yet) + // Reload menu using config site (fallback when no scan yet; no active session known → generic label). _ = LoadMenuForSiteAsync(null); } @@ -432,7 +502,7 @@ public partial class ScannerDashboardViewModel : ObservableObject EmployeeDepartmentType = !string.IsNullOrWhiteSpace(info.DepartmentType) ? info.DepartmentType : "—"; // Load menu using site from employee_rfid_tag.location_site_id (not config) if (int.TryParse(info.LocationSiteId?.Trim(), out var siteFromRfid)) - _ = LoadMenuForSiteAsync(siteFromRfid); + _ = LoadMenuForSiteAsync(siteFromRfid, result.MealSession); } // Refresh dashboard stats and order history _ = RefreshDashboardAfterSuccessfulScanAsync(cardId); @@ -500,11 +570,27 @@ public partial class ScannerDashboardViewModel : ObservableObject { LastCardId = "—"; LastScanTimeDisplay = "—"; + EmployeeId = "—"; + EmployeeName = "—"; + EmployeeDepartment = "—"; + EmployeeDepartmentType = "—"; + EmployeeOrderItem = "—"; return; } LastCardId = last.CardId; LastScanTimeDisplay = last.ScanTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + EmployeeId = string.IsNullOrWhiteSpace(last.EmployeeId) ? "—" : last.EmployeeId; + EmployeeName = string.IsNullOrWhiteSpace(last.EmployeeName) ? "—" : last.EmployeeName; + EmployeeDepartment = string.IsNullOrWhiteSpace(last.Department) ? "—" : last.Department; + EmployeeDepartmentType = string.IsNullOrWhiteSpace(last.DepartmentType) ? "—" : last.DepartmentType; + // For the left panel, prefer the meal/session label if present; otherwise fall back to items string. + if (!string.IsNullOrWhiteSpace(last.MealLabel)) + EmployeeOrderItem = last.MealLabel; + else if (!string.IsNullOrWhiteSpace(last.MealItems)) + EmployeeOrderItem = last.MealItems; + else + EmployeeOrderItem = "—"; } private void LoadOrderHistory() @@ -521,10 +607,13 @@ public partial class ScannerDashboardViewModel : ObservableObject { EmployeeId = employeeId, EmployeeName = employeeName, - Department = "—", + Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, ScanId = r.CardId ?? string.Empty, OrderTimeUtc = r.ScanTime, - OrderItem = _siteMealDisplay, + OrderItem = !string.IsNullOrWhiteSpace(r.MealItems) + ? r.MealItems + : (!string.IsNullOrWhiteSpace(r.MealLabel) ? r.MealLabel : "—"), + TotalPrice = r.TotalPrice > 0 ? r.TotalPrice : 0, TimeDisplay = local.ToString("hh:mm tt"), RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) }); @@ -547,10 +636,13 @@ public partial class ScannerDashboardViewModel : ObservableObject { EmployeeId = employeeId, EmployeeName = employeeName, - Department = "—", + Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, ScanId = r.CardId ?? string.Empty, OrderTimeUtc = r.ScanTime, - OrderItem = _siteMealDisplay, + OrderItem = !string.IsNullOrWhiteSpace(r.MealItems) + ? r.MealItems + : (!string.IsNullOrWhiteSpace(r.MealLabel) ? r.MealLabel : "—"), + TotalPrice = r.TotalPrice > 0 ? r.TotalPrice : 0, TimeDisplay = local.ToString("hh:mm tt"), RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) }); @@ -619,7 +711,7 @@ public partial class ScannerDashboardViewModel : ObservableObject private static string BuildCsvFromScanRecords(IReadOnlyList records) { var sb = new StringBuilder(); - sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress"); + sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress,Meal,Price"); foreach (var r in records) { sb.Append(r.Id); @@ -635,6 +727,10 @@ public partial class ScannerDashboardViewModel : ObservableObject sb.Append(EscapeCsv(r.DeviceId)); sb.Append(','); sb.Append(EscapeCsv(r.IpAddress)); + sb.Append(','); + sb.Append(EscapeCsv(r.MealItems ?? string.Empty)); + sb.Append(','); + sb.Append(r.TotalPrice.ToString("0.##", CultureInfo.InvariantCulture)); sb.AppendLine(); } return sb.ToString(); diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 59a790a..7bc9795 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -141,6 +141,13 @@ public partial class SettingsViewModel : ObservableObject _navigation.NavigateBackFromSettings(GetDashboardTimeout()); } + /// Opens Meal Schedules admin screen (Phase 2.5). + [RelayCommand] + private void OpenMealSchedules() + { + _navigation.NavigateToMealSchedules(); + } + [RelayCommand] private async Task PostDataNow() { diff --git a/Views/MealSchedulesView.xaml b/Views/MealSchedulesView.xaml new file mode 100644 index 0000000..d5fd1c7 --- /dev/null +++ b/Views/MealSchedulesView.xaml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +