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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/MealSchedulesView.xaml.cs b/Views/MealSchedulesView.xaml.cs
new file mode 100644
index 0000000..40a71a7
--- /dev/null
+++ b/Views/MealSchedulesView.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows.Controls;
+
+namespace UtopiaCanteenSystem.Views;
+
+/// Admin CRUD view for MealSchedules (Phase 2.5).
+public partial class MealSchedulesView : UserControl
+{
+ public MealSchedulesView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Views/ScannerDashboardView.xaml b/Views/ScannerDashboardView.xaml
index 5d9d62f..5bb278e 100644
--- a/Views/ScannerDashboardView.xaml
+++ b/Views/ScannerDashboardView.xaml
@@ -186,7 +186,7 @@
-
+
@@ -207,7 +207,7 @@
-
+
@@ -468,12 +468,14 @@
+
-
-
+
+
+
@@ -484,6 +486,7 @@
+
@@ -511,7 +514,13 @@
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="140"/>
-
+
+
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index c07afc0..4144d8a 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -392,6 +392,11 @@
+