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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +