Persist per-scan meal details and fix order history state across restarts

Store meal/session label, menu items, total price, department, and department type on each ScanRecord in SQLite so every scan keeps its own metadata.
Add lightweight schema upgrades for lunch_order_transactions to include MealLabel, MealItems, TotalPrice, Department, and DepartmentType.
Update RfidService to populate these new fields and expose UpdateLastScanMealInfo for post-scan enrichment.
Fix dashboard startup logic to restore last scanned employee (ID, name, department, department type, meal) from the last ScanRecord.
Refactor order history and “View All” modal to read Meal/Items and Price from each row’s stored values instead of a global in-memory label.
Include Meal and Price columns in the CSV export, using per-record data.
Remove unused local MealSchedules/OrderItems SQLite tables and ensure they are dropped as legacy schema.
pull/1/head
SYED MUSTUFA AHMED NAQVI 2026-03-02 15:04:33 +05:00
parent 888c692764
commit e496445b35
27 changed files with 1016 additions and 40 deletions

View File

@ -20,6 +20,9 @@
<DataTemplate DataType="{x:Type vm:SettingsViewModel}">
<views:SettingsView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MealSchedulesViewModel}">
<views:MealSchedulesView />
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -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);

View File

@ -0,0 +1,23 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace UtopiaCanteenSystem.Converters;
/// <summary>Converts MealSession int (0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner) to display name.</summary>
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();
}
}

View File

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

View File

@ -5,6 +5,10 @@ namespace UtopiaCanteenSystem.Models;
/// </summary>
public class HrmsMenuItem
{
/// <summary>menu_item.id</summary>
public int MenuItemId { get; set; }
public string ItemName { get; set; } = string.Empty;
public string ItemType { get; set; } = string.Empty;
/// <summary>menu_item.price (HRMS). Decimal is safest for money.</summary>
public decimal Price { get; set; }
}

20
Models/MealSchedule.cs Normal file
View File

@ -0,0 +1,20 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Meal timing window from production hrms.meal_schedule. id is bigint; location_site_id displayed as normalized string (e.g. "02").
/// </summary>
public class MealSchedule
{
public long Id { get; set; }
/// <summary>Site identifier (matches ScanRecord.SiteId / config).</summary>
public string LocationSiteId { get; set; } = string.Empty;
/// <summary>Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value).</summary>
public int MealSession { get; set; }
/// <summary>Window start time, stored as "HH:mm:ss".</summary>
public string StartTime { get; set; } = string.Empty;
/// <summary>Window end time, stored as "HH:mm:ss".</summary>
public string EndTime { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

15
Models/MealSession.cs Normal file
View File

@ -0,0 +1,15 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Meal session identifier. Stored as int in MealSchedules table.
/// None = no matching schedule window (scan rejected).
/// </summary>
public enum MealSession
{
/// <summary>No active schedule window; scan outside valid timings.</summary>
None = -1,
Breakfast = 0,
Lunch = 1,
Tea = 2,
Dinner = 3
}

View File

@ -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; }
/// <summary>The ordered item name (Sehri/Iftari).</summary>
public string OrderItem { get; set; } = "Sehri/Iftari";
/// <summary>The meal/menu label to display (e.g. Breakfast or "Biryani + Nihari").</summary>
public string OrderItem { get; set; } = string.Empty;
/// <summary>Total price for this row, based on the active meal's menu items.</summary>
public double TotalPrice { get; set; }
/// <summary>Display label: "Today", "Yesterday", or short date.</summary>
public string RelativeDateLabel { get; set; } = string.Empty;
/// <summary>Time only, e.g. "09:54 AM".</summary>

View File

@ -22,4 +22,14 @@ public class ScanRecord
public string EmployeeId { get; set; } = string.Empty;
/// <summary>Full name (first + middle) from HRMS at scan time, when lookup succeeded.</summary>
public string EmployeeName { get; set; } = string.Empty;
/// <summary>HRMS department title at scan time.</summary>
public string Department { get; set; } = string.Empty;
/// <summary>HRMS department type at scan time.</summary>
public string DepartmentType { get; set; } = string.Empty;
/// <summary>Meal/session label at scan time (e.g. Breakfast/Lunch/Tea/Dinner).</summary>
public string MealLabel { get; set; } = string.Empty;
/// <summary>Menu items string for this scan's meal (e.g. "Biryani + Nihari").</summary>
public string MealItems { get; set; } = string.Empty;
/// <summary>Total price for this scan's meal menu.</summary>
public double TotalPrice { get; set; }
}

View File

@ -0,0 +1,71 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Resolves current meal session from production (MySQL meal_schedule). Caches schedules per site for 60 seconds.
/// No SQLite; scan validation uses production data only.
/// </summary>
public class DbMealSessionResolver : IMealSessionResolver
{
private readonly IMealScheduleService _mealScheduleService;
private readonly TimeSpan _cacheTtl = TimeSpan.FromSeconds(60);
private readonly Dictionary<string, (List<MealSchedule> Schedules, DateTime ExpiryUtc)> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly object _cacheLock = new();
public DbMealSessionResolver(IMealScheduleService mealScheduleService)
{
_mealScheduleService = mealScheduleService;
}
/// <inheritdoc />
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<MealSchedule> 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);
}
}

View File

@ -18,6 +18,7 @@ public class EmployeeLookupService : IEmployeeLookupService
/// <inheritdoc />
public async Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))

View File

@ -0,0 +1,24 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Reads and writes meal schedules from production (MySQL/POD). No SQLite; sync job only syncs scan data.
/// </summary>
public interface IMealScheduleService
{
/// <summary>Active schedules for a site (used by resolver for scan validation). Returns empty if production not configured or error.</summary>
IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId);
/// <summary>All schedules for admin list. Returns empty if production not configured or error.</summary>
Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default);
/// <summary>Insert new schedule in production. Returns new id (bigint).</summary>
Task<long> CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default);
/// <summary>Update existing schedule in production.</summary>
Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default);
/// <summary>Delete schedule in production by id (bigint).</summary>
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,14 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Resolves current meal session from DB-driven schedule (Phase 2.5). Replaces hardcoded time windows.
/// </summary>
public interface IMealSessionResolver
{
/// <summary>
/// Returns the active meal session for the given local time and site, or MealSession.None if outside all windows.
/// </summary>
MealSession GetCurrentSession(DateTime nowLocal, string siteId);
}

View File

@ -16,6 +16,8 @@ public interface INavigationService
void NavigateToDashboard();
void NavigateToAdminSettingsAuth();
void NavigateToSettings();
/// <summary>Opens Meal Schedules admin screen (Phase 2.5).</summary>
void NavigateToMealSchedules();
/// <summary>Starts a new dashboard session (used after scan).</summary>
void StartDashboardSession();

View File

@ -50,4 +50,10 @@ public interface IRfidService
/// Returns number of scans recorded today (local day) for a given card ID.
/// </summary>
Task<int> GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice);
}

View File

@ -24,7 +24,7 @@ public class MenuLookupService : IMenuLookupService
return Array.Empty<HrmsMenuItem>();
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);
}
}

View File

@ -29,6 +29,7 @@ public class NavigationService : INavigationService
private readonly Func<MainDashboardViewModel> _dashboardVm;
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
private readonly Func<SettingsViewModel> _settingsVm;
private readonly Func<MealSchedulesViewModel> _mealSchedulesVm;
public NavigationService(
AppSession session,
@ -36,7 +37,8 @@ public class NavigationService : INavigationService
Func<ScannerDashboardViewModel> scannerVm,
Func<MainDashboardViewModel> dashboardVm,
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
Func<SettingsViewModel> settingsVm)
Func<SettingsViewModel> settingsVm,
Func<MealSchedulesViewModel> 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;

View File

@ -0,0 +1,207 @@
using MySqlConnector;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// 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.
/// </summary>
public class ProductionMealScheduleService : IMealScheduleService
{
private readonly IConfigService _configService;
private const string TableRef = "`hrms`.`meal_schedule`";
public ProductionMealScheduleService(IConfigService configService)
{
_configService = configService;
}
/// <inheritdoc />
public IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId)
{
var connStr = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connStr))
return Array.Empty<MealSchedule>();
var siteIdInt = SiteIdStringToInt(siteId);
var list = new List<MealSchedule>();
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<MealSchedule>();
}
return list;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default)
{
var connStr = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connStr))
return Array.Empty<MealSchedule>();
var list = new List<MealSchedule>();
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;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
/// <summary>Read row: id (bigint), meal_name, start_time (time), end_time (time), created_at, updated_at, location_site_id (int).</summary>
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;
}
/// <summary>Normalize production location_site_id int to display string (e.g. 2 -> "02").</summary>
private static string IntToSiteIdString(int siteIdInt)
{
if (siteIdInt <= 0) return "01";
return siteIdInt <= 99 ? siteIdInt.ToString("D2") : siteIdInt.ToString();
}
}

View File

@ -19,12 +19,14 @@ public class RfidService : IRfidService
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
private readonly IEmployeeLookupService _employeeLookup;
private readonly IMealSessionResolver _mealSessionResolver;
public RfidService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup)
public RfidService(IDbContextFactory<AppDbContext> 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)

View File

@ -4,11 +4,13 @@ namespace UtopiaCanteenSystem.Services;
/// <summary>
/// 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.
/// </summary>
public readonly record struct ScanResult(
bool Success,
string Message,
int CooldownSecondsRemaining,
HrmsEmployeeInfo? EmployeeInfo = null);
HrmsEmployeeInfo? EmployeeInfo = null,
MealSession MealSession = MealSession.None);

View File

@ -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;
/// <summary>
/// Admin CRUD for meal schedules in production (hrms.meal_schedule). No SQLite. Load all on open; optional Site filter (local).
/// </summary>
public partial class MealSchedulesViewModel : ObservableObject
{
private readonly IMealScheduleService _mealScheduleService;
private readonly INavigationService _navigation;
[ObservableProperty]
private ObservableCollection<MealSchedule> _schedules = new();
/// <summary>Full list from production; Schedules is filtered by SelectedSiteFilter.</summary>
private List<MealSchedule> _allSchedules = new();
[ObservableProperty]
private ObservableCollection<string> _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<MealSchedule>(_allSchedules);
return;
}
var filtered = _allSchedules.Where(s => string.Equals(s.LocationSiteId?.Trim(), SelectedSiteFilter.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
Schedules = new ObservableCollection<MealSchedule>(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<string>(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();
}
}

View File

@ -121,13 +121,19 @@ public partial class ScannerDashboardViewModel : ObservableObject
[ObservableProperty]
private string _employeeDepartmentType = "—";
/// <summary>Meal(s) for the order from HRMS menu (e.g. "Sehri / Iftari"). Fetched by site.</summary>
/// <summary>Meal label for the current scan (e.g. Breakfast / Lunch / Tea / Dinner) from hrms.meal_schedule.</summary>
[ObservableProperty]
private string _employeeOrderItem = "Sehri/Iftari";
/// <summary>Current site meal display for order history rows (from HRMS menu).</summary>
/// <summary>
/// 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.
/// </summary>
private string _siteMealDisplay = "Sehri/Iftari";
/// <summary>Total price for the current meal's menu (sum of matching items' prices).</summary>
private double _siteMealTotalPrice;
/// <summary>Optional profile image path; null = show placeholder.</summary>
[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();
}
/// <summary>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.</summary>
private async Task LoadMenuForSiteAsync(int? siteIdFromScan = null)
/// <summary>
/// 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.
/// </summary>
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<ScanRecord> 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();

View File

@ -141,6 +141,13 @@ public partial class SettingsViewModel : ObservableObject
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
}
/// <summary>Opens Meal Schedules admin screen (Phase 2.5).</summary>
[RelayCommand]
private void OpenMealSchedules()
{
_navigation.NavigateToMealSchedules();
}
[RelayCommand]
private async Task PostDataNow()
{

View File

@ -0,0 +1,117 @@
<UserControl x:Class="UtopiaCanteenSystem.Views.MealSchedulesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:UtopiaCanteenSystem.Converters">
<UserControl.Resources>
<converters:MealSessionToNameConverter x:Key="MealSessionConverter"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
<SolidColorBrush x:Key="MutedText" Color="#718096"/>
<SolidColorBrush x:Key="PrimaryAccent" Color="#5BA3A0"/>
<SolidColorBrush x:Key="BorderBrush" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#E53E3E"/>
<Style x:Key="AccentButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryAccent}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="MinHeight" Value="36"/>
</Style>
<Style x:Key="OutlineButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryText}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="MinHeight" Value="36"/>
</Style>
</UserControl.Resources>
<Grid Background="#F0F2F5" Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Title + Back + Site filter -->
<Grid Grid.Row="0" Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="← Back" Command="{Binding BackCommand}" Style="{StaticResource OutlineButton}" Margin="0,0,16,0"/>
<TextBlock Grid.Column="1" Text="Meal Schedules (production hrms.meal_schedule)" FontSize="22" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Site:" VerticalAlignment="Center" Margin="0,0,8,0" Foreground="{StaticResource MutedText}"/>
<ComboBox ItemsSource="{Binding SiteFilterChoices}" SelectedItem="{Binding SelectedSiteFilter, Mode=TwoWay}" MinWidth="80" MinHeight="32" Padding="8,4"/>
</StackPanel>
</Grid>
<!-- DataGrid -->
<DataGrid Grid.Row="1" ItemsSource="{Binding Schedules}" SelectedItem="{Binding SelectedSchedule, Mode=TwoWay}"
AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" CanUserDeleteRows="False"
HeadersVisibility="Column" GridLinesVisibility="Horizontal" BorderThickness="1" BorderBrush="{StaticResource BorderBrush}"
Margin="0,0,0,16" MinHeight="120">
<DataGrid.Columns>
<DataGridTextColumn Header="Site" Binding="{Binding LocationSiteId}" Width="*"/>
<DataGridTextColumn Header="Session" Binding="{Binding MealSession, Converter={StaticResource MealSessionConverter}}" Width="*"/>
<DataGridTextColumn Header="Start" Binding="{Binding StartTime}" Width="*"/>
<DataGridTextColumn Header="End" Binding="{Binding EndTime}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
<!-- Edit form -->
<Border Grid.Row="2" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="16" Margin="0,0,0,12" Background="White">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="Location Site ID" FontSize="12" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding LocationSiteId, UpdateSourceTrigger=PropertyChanged}" Padding="8,6" MinHeight="32"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0">
<TextBlock Text="Meal Session" FontSize="12" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<ComboBox SelectedIndex="{Binding MealSessionIndex, Mode=TwoWay}" MinHeight="32" Padding="8,4">
<ComboBoxItem Content="Breakfast"/>
<ComboBoxItem Content="Lunch"/>
<ComboBoxItem Content="Tea"/>
<ComboBoxItem Content="Dinner"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2" Margin="8,0">
<TextBlock Text="Start (HH:mm:ss)" FontSize="12" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding StartTime, UpdateSourceTrigger=PropertyChanged}" Padding="8,6" MinHeight="32"/>
</StackPanel>
<StackPanel Grid.Column="3" Margin="8,0">
<TextBlock Text="End (HH:mm:ss)" FontSize="12" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding EndTime, UpdateSourceTrigger=PropertyChanged}" Padding="8,6" MinHeight="32"/>
</StackPanel>
<StackPanel Grid.Column="4" VerticalAlignment="Bottom" Orientation="Horizontal">
<Button Content="Add new" Command="{Binding AddNewCommand}" Style="{StaticResource OutlineButton}" Margin="0,0,8,0"/>
<Button Content="Save" Command="{Binding SaveCommand}" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/>
<Button Content="Delete" Command="{Binding DeleteCommand}" Style="{StaticResource OutlineButton}"/>
</StackPanel>
</Grid>
</Border>
<!-- Message -->
<TextBlock Grid.Row="3" Text="{Binding Message}" Foreground="{StaticResource PrimaryText}" FontSize="14">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource PrimaryText}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</UserControl>

View File

@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace UtopiaCanteenSystem.Views;
/// <summary>Admin CRUD view for MealSchedules (Phase 2.5).</summary>
public partial class MealSchedulesView : UserControl
{
public MealSchedulesView()
{
InitializeComponent();
}
}

View File

@ -186,7 +186,7 @@
<TextBlock Text="{Binding EmployeeDepartment}" FontSize="19" FontWeight="SemiBold" Foreground="{StaticResource TitleTextBrush}" Margin="0,0,0,18" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="Employee ID:" FontSize="15" Foreground="{StaticResource MutedTextBrush}" Margin="0,0,0,4"/>
<TextBlock Text="{Binding EmployeeId}" FontSize="19" FontWeight="SemiBold" Foreground="{StaticResource TitleTextBrush}" Margin="0,0,0,18" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="Order" FontSize="15" Foreground="{StaticResource MutedTextBrush}" Margin="0,0,0,4"/>
<TextBlock Text="Meal" FontSize="15" Foreground="{StaticResource MutedTextBrush}" Margin="0,0,0,4"/>
<TextBlock Text="{Binding EmployeeOrderItem}" FontSize="19" FontWeight="SemiBold" Foreground="{StaticResource TitleTextBrush}" TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/>
<!-- Order History card (dashboard green accent) -->
<Border Margin="0,20,0,0" Background="White" BorderBrush="#e2e8f0" BorderThickness="1" CornerRadius="12" Padding="18">
@ -207,7 +207,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Employee" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}"/>
<TextBlock Grid.Column="1" Text="Scan ID" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Order" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Meal" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="Time" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Right"/>
</Grid>
<Border Height="1" Background="#e2e8f0" Margin="0,8,0,0"/>
@ -468,12 +468,14 @@
<ColumnDefinition Width="*" MinWidth="0"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="90"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Employee" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}"/>
<TextBlock Grid.Column="1" Text="Scan ID" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Order" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="Time" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="2" Text="Meal" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="Price" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="Time" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource MutedTextBrush}" HorizontalAlignment="Right"/>
</Grid>
<ItemsControl ItemsSource="{Binding TodayOrderHistory}">
<ItemsControl.ItemTemplate>
@ -484,6 +486,7 @@
<ColumnDefinition Width="*" MinWidth="0"/>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="90"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
@ -511,7 +514,13 @@
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="140"/>
<StackPanel Grid.Column="3" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Grid.Column="3"
Text="{Binding TotalPrice, StringFormat='Rs. {0:0.##}'}"
FontSize="13"
Foreground="{StaticResource TitleTextBrush}"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
<StackPanel Grid.Column="4" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="{Binding TimeDisplay}" FontSize="13" FontWeight="Bold" Foreground="{StaticResource TitleTextBrush}"/>
<TextBlock Text="{Binding RelativeDateLabel}" FontSize="11" Foreground="{StaticResource MutedTextBrush}" Margin="0,1,0,0"/>
</StackPanel>

View File

@ -392,6 +392,11 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<Button Content="Meal Schedules"
Command="{Binding OpenMealSchedulesCommand}"
Style="{StaticResource OutlineButtonStyle}"
MinWidth="120"
Margin="0,0,16,0" />
<Button Content="Back"
Command="{Binding BackCommand}"
Style="{StaticResource OutlineButtonStyle}"