using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Text; using DebounceTimer = System.Timers.Timer; using Microsoft.Win32; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using UtopiaCanteenSystem.Models; using UtopiaCanteenSystem.Services; namespace UtopiaCanteenSystem.ViewModels; /// /// Single-screen ViewModel that combines continuous scanning + dashboard info. /// public partial class ScannerDashboardViewModel : ObservableObject { private const int DebounceMs = 200; private const int CooldownTickSeconds = 1; private static readonly TimeSpan ScannerInactivityTimeout = TimeSpan.FromSeconds(10); private readonly IRfidService _rfidService; private readonly INavigationService _navigation; private readonly AppSession _session; private readonly IConfigService _configService; private readonly DispatcherTimer _scannerStatusTimer; private DateTime? _lastScanActivityUtc; private readonly Dispatcher _uiDispatcher; private readonly DebounceTimer _debounceTimer; private readonly DispatcherTimer _cooldownTimer; private DateTime? _cooldownEndsUtc; private int _lastDisplayedCooldownSeconds = -1; private bool _isCooldownActive; private string _cooldownBlockedCardId = string.Empty; private bool _suppressCooldownRewrite; private readonly DispatcherTimer _clockTimer; private readonly object _submitLock = new(); private bool _isSubmitting; // --- Scanner input --- [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsWatermarkVisible))] private string _cardIdInput = string.Empty; [ObservableProperty] private bool _isProcessing; [ObservableProperty] private bool _isCooldownActiveUi; public bool IsWatermarkVisible => string.IsNullOrWhiteSpace(CardIdInput); [ObservableProperty] [NotifyPropertyChangedFor(nameof(ScannerStatusDisplay))] private string _scannerStatus = "Disconnected"; /// Shows scanner device connectivity: connected = ready; disconnected = check device. public string ScannerStatusDisplay => string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Scanner connected" : "Scanner not connected"; [ObservableProperty] private string _currentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt"); // --- Dashboard fields --- [ObservableProperty] private int _todaysScans; [ObservableProperty] private int _totalOrders; [ObservableProperty] private string _lastCardId = "—"; [ObservableProperty] private string _lastScanTimeDisplay = "—"; /// Prominent alert when customer scanned during cooldown: ask them to rescan after countdown. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowCooldownAlert))] private string _cooldownAlertMessage = string.Empty; public bool ShowCooldownAlert => !string.IsNullOrWhiteSpace(CooldownAlertMessage); // Message area (green/red) [ObservableProperty] private string _message = string.Empty; [ObservableProperty] private bool _isSuccess; public bool IsAdminAuthenticated => _session.IsAdminAuthenticated; // --- Menu + site selection --- [ObservableProperty] private bool _isMenuOpen; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentSiteDisplay))] private string _siteNumber = "1"; /// Display string for current site (e.g. "SITE : 1"). public string CurrentSiteDisplay => string.IsNullOrWhiteSpace(SiteNumber) ? "SITE : 1" : $"SITE : {SiteNumber.Trim()}"; // --- Left panel: Employee information (from HRMS lookup after scan) --- [ObservableProperty] private string _employeeName = "—"; [ObservableProperty] private string _employeeDepartment = "—"; [ObservableProperty] private string _employeeId = "—"; /// Department type from HRMS (e.g. displayed under name). [ObservableProperty] private string _employeeDepartmentType = "—"; /// Meal label for the current scan (e.g. Breakfast / Lunch / Tea / Dinner) from hrms.meal_schedule. [ObservableProperty] private string _employeeOrderItem = "Sehri/Iftari"; /// /// 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; /// Employee photo from hrms.employee_photo (in-memory); null = show placeholder. [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasEmployeeProfileImage))] private ImageSource? _employeeProfileImage; public bool HasEmployeeProfileImage => EmployeeProfileImage != null; /// Last 4 orders for the Order History card (newest first). public ObservableCollection OrderHistory { get; } = new(); /// All orders for today, for the \"View All\" modal (newest first). public ObservableCollection TodayOrderHistory { get; } = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasOrderHistory))] private bool _orderHistoryEmpty; public bool HasOrderHistory => !OrderHistoryEmpty; [ObservableProperty] private bool _isOrderHistoryModalOpen; private readonly IMenuLookupService _menuLookupService; private readonly IEmployeePhotoService _employeePhotoService; public ScannerDashboardViewModel( IRfidService rfidService, INavigationService navigation, AppSession session, IConfigService configService, IMenuLookupService menuLookupService, IEmployeePhotoService employeePhotoService) { _rfidService = rfidService; _navigation = navigation; _session = session; _configService = configService; _menuLookupService = menuLookupService; _employeePhotoService = employeePhotoService; _uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; // Debounce auto-submit (keyboard wedge scanners). _debounceTimer = new DebounceTimer(DebounceMs) { AutoReset = false }; _debounceTimer.Elapsed += (_, _) => { _uiDispatcher.BeginInvoke(() => { lock (_submitLock) { if (_isSubmitting || IsProcessing || _isCooldownActive) return; } if (string.IsNullOrWhiteSpace(CardIdInput)) return; if (ScanCommand.CanExecute(null)) ScanCommand.Execute(null); }, DispatcherPriority.Background); }; // Cooldown countdown updates. _cooldownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CooldownTickSeconds) }; _cooldownTimer.Tick += (_, _) => UpdateCooldownMessage(); // Activity-based scanner status (keyboard-wedge style scanners). _scannerStatusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _scannerStatusTimer.Tick += (_, _) => UpdateScannerStatusFromActivity(); _scannerStatusTimer.Start(); // Current time display. _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _clockTimer.Tick += (_, _) => CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt"); _clockTimer.Start(); // Load initial site from config. var siteId = _configService.GetSiteId(); if (!string.IsNullOrWhiteSpace(siteId)) { // Extract digits from legacy format "SITE : X"or use value directly if already numeric if (siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase)) { var num = siteId.Substring("SITE : ".Length).Trim(); if (num.Length > 0 && num.All(char.IsDigit)) SiteNumber = num; } else if (siteId.All(char.IsDigit)) { SiteNumber = siteId; } } 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. /// 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) siteIdNumeric = siteIdFromScan.Value; else if (!int.TryParse(SiteNumber?.Trim(), out siteIdNumeric) || siteIdNumeric < 0) return; try { // Fetch all menu items for this site (current week only) from HRMS. var items = await _menuLookupService.GetMenuItemsForSiteAsync(siteIdNumeric).ConfigureAwait(false); 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" //}; 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 matchingItems = items //.Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) //.ToList(); var matchingItems = items.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 = mealLabel; LoadOrderHistory(); if (IsOrderHistoryModalOpen) LoadTodayOrderHistory(); }); } catch { _siteMealDisplay = "No active meal session"; _siteMealTotalPrice = 0; _uiDispatcher.Invoke(() => { EmployeeOrderItem = "No active meal session"; LoadOrderHistory(); if (IsOrderHistoryModalOpen) LoadTodayOrderHistory(); }); } } private static string GetRelativeDateLabel(DateTime utcTime) { var localDate = utcTime.ToLocalTime().Date; var today = DateTime.Today; if (localDate == today) return "Today"; if (localDate == today.AddDays(-1)) return "Yesterday"; return localDate.ToString("MMM d"); } partial void OnSiteNumberChanged(string value) { // Numeric only: filter to digits so display stays valid. var digits = value == null ? string.Empty : new string(value.Where(char.IsDigit).ToArray()); if (digits != value) { SiteNumber = digits; return; } // 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; no active session known → generic label). _ = LoadMenuForSiteAsync(null); } partial void OnCardIdInputChanged(string value) { // If the field is cleared, also clear any remembered blocked-id prefix. if (string.IsNullOrEmpty(value)) _cooldownBlockedCardId = string.Empty; // While a cooldown is active, keep showing the blocked card id and ignore any typed input // (keyboard wedge scanners can still send keystrokes). if (_isCooldownActive && !_suppressCooldownRewrite && !string.IsNullOrEmpty(_cooldownBlockedCardId) && !string.Equals(value, _cooldownBlockedCardId, StringComparison.Ordinal)) { try { _suppressCooldownRewrite = true; CardIdInput = _cooldownBlockedCardId; return; } finally { _suppressCooldownRewrite = false; } } // If a cooldown is active and the next scan starts typing into the existing text, // strip the previous blocked card id so the new scan doesn't append to it. if (!_suppressCooldownRewrite && !string.IsNullOrEmpty(_cooldownBlockedCardId) && value.Length > _cooldownBlockedCardId.Length && value.StartsWith(_cooldownBlockedCardId, StringComparison.Ordinal)) { try { _suppressCooldownRewrite = true; CardIdInput = value.Substring(_cooldownBlockedCardId.Length); _cooldownBlockedCardId = string.Empty; return; } finally { _suppressCooldownRewrite = false; } } // Any incoming characters means activity from the scanner keyboard wedge. if (!string.IsNullOrWhiteSpace(value)) { _lastScanActivityUtc = DateTime.UtcNow; ScannerStatus = "Connected"; } // Clear any previous message once a new scan starts (outside cooldown). if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message)) Message = string.Empty; // Don't auto-submit while cooldown is active or while we're processing. if (!_isCooldownActive && !IsProcessing) RestartDebounceTimer(); } private void RestartDebounceTimer() { _debounceTimer.Stop(); if (string.IsNullOrWhiteSpace(CardIdInput)) return; _debounceTimer.Start(); } private void RefreshScannerStatus() { ScannerStatus = "Disconnected"; } private void UpdateScannerStatusFromActivity() { if (_lastScanActivityUtc is null) { if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) ScannerStatus = "Disconnected"; return; } var inactiveFor = DateTime.UtcNow - _lastScanActivityUtc.Value; if (inactiveFor > ScannerInactivityTimeout && !string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) { ScannerStatus = "Disconnected"; } } [RelayCommand] private void Logout() { _session.Logout(); _navigation.NavigateToAdminLogin(); } [RelayCommand] private void OpenSettings() { if (!_session.IsAdminAuthenticated) return; // Kiosk mode: require re-auth for settings. _navigation.NavigateToAdminSettingsAuth(); } //[RelayCommand] //private void Scan() //{ // lock (_submitLock) // { // if (_isSubmitting || IsProcessing || _isCooldownActive) // return; // _isSubmitting = true; // IsProcessing = true; // } // try // { // _debounceTimer.Stop(); // Message = string.Empty; // IsSuccess = false; // var cardId = CardIdInput?.Trim() ?? string.Empty; // if (string.IsNullOrWhiteSpace(cardId)) // return; // var result = _rfidService.ProcessScanDetailed(cardId); // IsSuccess = result.Success; // Message = result.Message; // if (!result.Success && result.CooldownSecondsRemaining > 0) // { // // Requirement: when blocked due to cooldown, keep the scanned ID visible // // so users understand which card was blocked. // CardIdInput = cardId; // StartCooldownCountdown(result.CooldownSecondsRemaining); // return; // } // // Clear input for normal success/failure so the next scan starts cleanly. // CardIdInput = string.Empty; // if (result.Success) // { // // Update left panel with HRMS employee data from this scan // if (result.EmployeeInfo is { } info) // { // EmployeeId = !string.IsNullOrWhiteSpace(info.EmployeeId) ? info.EmployeeId : "—"; // var fullName = string.Join(" ", new[] { info.FirstName, info.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); // EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; // EmployeeDepartment = !string.IsNullOrWhiteSpace(info.DepartmentTitle) ? info.DepartmentTitle : "—"; // 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, result.MealSession); // // Load employee photo from hrms.employee_photo by parent_document_id (employee document id) // _ = LoadEmployeePhotoAsync(info.ParentDocumentId); // } // // Refresh dashboard stats and order history // _ = RefreshDashboardAfterSuccessfulScanAsync(cardId); // } // } // finally // { // lock (_submitLock) // { // _isSubmitting = false; // // Cooldown keeps the UI locked; otherwise unlock after scan completes. // if (!_isCooldownActive) // IsProcessing = false; // } // } //} [RelayCommand] private void Scan() { lock (_submitLock) { if (_isSubmitting || IsProcessing || _isCooldownActive) return; _isSubmitting = true; IsProcessing = true; } try { _debounceTimer.Stop(); Message = string.Empty; IsSuccess = false; var cardId = CardIdInput?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(cardId)) return; var result = _rfidService.ProcessScanDetailed(cardId); IsSuccess = result.Success; Message = result.Message; // Check if this is a site mismatch error if (!result.Success && result.Message.Contains("not allowed to scan here")) { // Clear input for site mismatch so the next scan can start fresh CardIdInput = string.Empty; // The employee info is included so we can show who tried to scan if (result.EmployeeInfo != null) { var fullName = string.Join(" ", new[] { result.EmployeeInfo.FirstName, result.EmployeeInfo.MiddleName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); // Optionally update the UI with the employee info even though scan failed EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; EmployeeId = !string.IsNullOrWhiteSpace(result.EmployeeInfo.EmployeeId) ? result.EmployeeInfo.EmployeeId : "—"; EmployeeDepartment = !string.IsNullOrWhiteSpace(result.EmployeeInfo.DepartmentTitle) ? result.EmployeeInfo.DepartmentTitle : "—"; EmployeeDepartmentType = !string.IsNullOrWhiteSpace(result.EmployeeInfo.DepartmentType) ? result.EmployeeInfo.DepartmentType : "—"; // Load employee photo _ = LoadEmployeePhotoAsync(result.EmployeeInfo.ParentDocumentId); } return; } if (!result.Success && result.CooldownSecondsRemaining > 0) { // Keep the scanned ID visible during cooldown CardIdInput = cardId; StartCooldownCountdown(result.CooldownSecondsRemaining); return; } // Clear input for normal success/failure so the next scan starts cleanly. CardIdInput = string.Empty; if (result.Success) { // Update left panel with HRMS employee data from this scan if (result.EmployeeInfo is { } info) { EmployeeId = !string.IsNullOrWhiteSpace(info.EmployeeId) ? info.EmployeeId : "—"; var fullName = string.Join(" ", new[] { info.FirstName, info.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; EmployeeDepartment = !string.IsNullOrWhiteSpace(info.DepartmentTitle) ? info.DepartmentTitle : "—"; EmployeeDepartmentType = !string.IsNullOrWhiteSpace(info.DepartmentType) ? info.DepartmentType : "—"; // Load employee photo _ = LoadEmployeePhotoAsync(info.ParentDocumentId); } // Refresh dashboard stats and order history _ = RefreshDashboardAfterSuccessfulScanAsync(cardId); } } finally { lock (_submitLock) { _isSubmitting = false; // Cooldown keeps the UI locked; otherwise unlock after scan completes. if (!_isCooldownActive) IsProcessing = false; } } } private async Task RefreshDashboardAfterSuccessfulScanAsync(string cardId) { await RefreshDashboardAsync().ConfigureAwait(false); // If the same card scanned 2+ times today, show a "scanned again" message. try { var todayCountForCard = await _rfidService.GetTodayScanCountForCardAsync(cardId).ConfigureAwait(false); if (todayCountForCard >= 2) { _uiDispatcher.Invoke(() => { IsSuccess = true; Message = $"Welcome back! Another order (#{todayCountForCard} today)."; }); } } catch { // Ignore; keep original message. } } private async Task RefreshDashboardAsync() { try { var todayCount = await _rfidService.GetTodayScanCountAsync().ConfigureAwait(false); var last = _rfidService.GetLastScan(); _uiDispatcher.Invoke(() => { TodaysScans = todayCount; TotalOrders = todayCount; // Total orders = today's total only (not previous days) ApplyLastScan(last); LoadOrderHistory(); }); } catch { // If DB is unavailable, fail gracefully. } } private void ApplyLastScan(ScanRecord? last) { if (last == null) { LastCardId = "—"; LastScanTimeDisplay = "—"; EmployeeId = "—"; EmployeeName = "—"; EmployeeDepartment = "—"; EmployeeDepartmentType = "—"; EmployeeOrderItem = "—"; EmployeeProfileImage = null; 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 = "—"; // Load photo for last scanned employee (e.g. on app restart) using parent_document_id _ = LoadEmployeePhotoAsync(string.IsNullOrWhiteSpace(last.ParentDocumentId) ? null : last.ParentDocumentId); } /// /// Loads employee photo from hrms.employee_photo by parent_document_id and sets EmployeeProfileImage on the UI thread. /// Fails gracefully: no photo or decode error leaves placeholder (null). /// private async Task LoadEmployeePhotoAsync(string? parentDocumentId) { if (string.IsNullOrWhiteSpace(parentDocumentId)) { _uiDispatcher.Invoke(() => EmployeeProfileImage = null); return; } try { var bytes = await _employeePhotoService.GetPhotoBytesAsync(parentDocumentId.Trim()).ConfigureAwait(false); _uiDispatcher.Invoke(() => { if (bytes == null || bytes.Length == 0) { EmployeeProfileImage = null; return; } try { using var ms = new MemoryStream(bytes); var bmp = new BitmapImage(); bmp.BeginInit(); bmp.CacheOption = BitmapCacheOption.OnLoad; bmp.StreamSource = ms; bmp.EndInit(); bmp.Freeze(); EmployeeProfileImage = bmp; } catch { EmployeeProfileImage = null; } }); } catch { _uiDispatcher.Invoke(() => EmployeeProfileImage = null); } } private void LoadOrderHistory() { var scans = _rfidService.GetLastScans(4); OrderHistory.Clear(); foreach (var r in scans) { var local = r.ScanTime.ToLocalTime(); var employeeId = !string.IsNullOrWhiteSpace(r.EmployeeId) ? r.EmployeeId : "—"; var employeeName = !string.IsNullOrWhiteSpace(r.EmployeeName) ? r.EmployeeName : "—"; var photo = CreateImageSourceFromPhoto(r.ParentDocumentId); OrderHistory.Add(new OrderHistoryItem { ParentDocumentId = r.ParentDocumentId ?? string.Empty, EmployeeId = employeeId, EmployeeName = employeeName, Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, ScanId = r.CardId ?? string.Empty, OrderTimeUtc = r.ScanTime, 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), EmployeePhoto = photo }); } OrderHistoryEmpty = OrderHistory.Count == 0; } private void LoadTodayOrderHistory() { var scans = _rfidService.GetScansForToday(); TodayOrderHistory.Clear(); foreach (var r in scans) { var local = r.ScanTime.ToLocalTime(); var employeeId = !string.IsNullOrWhiteSpace(r.EmployeeId) ? r.EmployeeId : "—"; var employeeName = !string.IsNullOrWhiteSpace(r.EmployeeName) ? r.EmployeeName : "—"; var photo = CreateImageSourceFromPhoto(r.ParentDocumentId); TodayOrderHistory.Add(new OrderHistoryItem { ParentDocumentId = r.ParentDocumentId ?? string.Empty, EmployeeId = employeeId, EmployeeName = employeeName, Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, ScanId = r.CardId ?? string.Empty, OrderTimeUtc = r.ScanTime, 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), EmployeePhoto = photo }); } } /// /// Helper to synchronously create an ImageSource for order history rows using cached employee photos. /// Returns null on any failure and does not throw. /// private ImageSource? CreateImageSourceFromPhoto(string? parentDocumentId) { try { if (string.IsNullOrWhiteSpace(parentDocumentId)) return null; var bytes = _employeePhotoService.GetPhotoBytesAsync(parentDocumentId.Trim()) .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (bytes == null || bytes.Length == 0) return null; using var ms = new MemoryStream(bytes); var bmp = new BitmapImage(); bmp.BeginInit(); bmp.CacheOption = BitmapCacheOption.OnLoad; bmp.StreamSource = ms; bmp.EndInit(); bmp.Freeze(); return bmp; } catch { return null; } } [RelayCommand] private void OpenOrderHistoryModal() { // Delay opening by one UI tick to prevent the same mouse click from immediately closing the modal _uiDispatcher.BeginInvoke(() => { LoadTodayOrderHistory(); IsOrderHistoryModalOpen = true; }, DispatcherPriority.Loaded); } [RelayCommand] private void CloseOrderHistoryModal() { IsOrderHistoryModalOpen = false; } /// /// Exports today's scan records to a CSV file (opens in Excel). User chooses path via Save File dialog. /// [RelayCommand] private void DownloadTodayRecords() { var records = _rfidService.GetScansForToday(); if (records.Count == 0) { Message = "No records for today to download."; IsSuccess = false; return; } var dateStr = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var defaultFileName = $"CanteenRecords_{dateStr}.csv"; var dialog = new SaveFileDialog { Filter = "CSV (Excel)|*.csv|All files|*.*", DefaultExt = ".csv", FileName = defaultFileName }; if (dialog.ShowDialog() != true) return; try { var csv = BuildCsvFromScanRecords(records); var utf8WithBom = new UTF8Encoding(true); File.WriteAllText(dialog.FileName, csv, utf8WithBom); Message = $"Downloaded {records.Count} record(s) to {Path.GetFileName(dialog.FileName)}"; IsSuccess = true; } catch (Exception ex) { Message = "Download failed: " + ex.Message; IsSuccess = false; } } private static string ToExcelText(string? value) { var safe = value ?? string.Empty; safe = safe.Replace("\"", "\"\""); return $"=\"{safe}\""; } private static string BuildCsvFromScanRecords(IReadOnlyList records) { var sb = new StringBuilder(); sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress,EmployeeId,EmployeeName,Meal,Price"); foreach (var r in records) { sb.Append(r.Id); sb.Append(','); sb.Append(ToExcelText(r.CardId)); sb.Append(','); sb.Append(EscapeCsv(r.ScanTime.ToString("O", CultureInfo.InvariantCulture))); sb.Append(','); sb.Append(r.IsSynced ? "Yes" : "No"); sb.Append(','); sb.Append(EscapeCsv(r.SiteId)); sb.Append(','); sb.Append(EscapeCsv(r.DeviceId)); sb.Append(','); sb.Append(EscapeCsv(r.IpAddress)); sb.Append(','); sb.Append(EscapeCsv(r.EmployeeId ?? string.Empty)); sb.Append(','); sb.Append(EscapeCsv(r.EmployeeName ?? string.Empty)); sb.Append(','); sb.Append(EscapeCsv(r.MealItems ?? string.Empty)); sb.Append(','); sb.Append(r.TotalPrice.ToString("0.##", CultureInfo.InvariantCulture)); sb.AppendLine(); } return sb.ToString(); } private static string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) return value; if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return value; return "\"" + value.Replace("\"", "\"\"") + "\""; } private void StartCooldownCountdown(int seconds) { if (seconds <= 0) return; _isCooldownActive = true; IsCooldownActiveUi = true; IsProcessing = true; // keep textbox disabled during cooldown _cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty; _cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds); _lastDisplayedCooldownSeconds = -1; Message = string.Empty; // single alert only: cooldown box CooldownAlertMessage = FormatCooldownMessage(seconds); UpdateCooldownMessage(); _cooldownTimer.Start(); } private void StopCooldownCountdown() { _cooldownTimer.Stop(); _cooldownEndsUtc = null; _lastDisplayedCooldownSeconds = -1; _isCooldownActive = false; IsCooldownActiveUi = false; IsProcessing = false; CooldownAlertMessage = string.Empty; } /// Formats remaining seconds as one concise phrase in the best unit (e.g. "2 days", "1 minute", "30 seconds"). private static string FormatRemainingInBestUnit(int totalSeconds) { if (totalSeconds <= 0) return "0 seconds"; if (totalSeconds >= 86400) { var d = totalSeconds / 86400; return d == 1 ? "1 day" : $"{d} days"; } if (totalSeconds >= 3600) { var h = totalSeconds / 3600; return h == 1 ? "1 hour" : $"{h} hours"; } if (totalSeconds >= 60) { var m = totalSeconds / 60; return m == 1 ? "1 minute" : $"{m} minutes"; } return totalSeconds == 1 ? "1 second" : $"{totalSeconds} seconds"; } /// Single concise cooldown line for the one visible alert. private static string FormatCooldownMessage(int remainingSeconds) { var phrase = FormatRemainingInBestUnit(remainingSeconds); return $"Rescan in {phrase}."; } private void UpdateCooldownMessage() { if (_cooldownEndsUtc is null) { CooldownAlertMessage = string.Empty; return; } var remaining = (int)Math.Ceiling((_cooldownEndsUtc.Value - DateTime.UtcNow).TotalSeconds); if (remaining <= 0) { StopCooldownCountdown(); CooldownAlertMessage = string.Empty; Message = "Ready for next order."; IsSuccess = true; return; } if (remaining == _lastDisplayedCooldownSeconds) return; _lastDisplayedCooldownSeconds = remaining; Message = string.Empty; // single alert only CooldownAlertMessage = FormatCooldownMessage(remaining); IsSuccess = false; } ~ScannerDashboardViewModel() { _scannerStatusTimer.Stop(); _cooldownTimer.Stop(); _clockTimer.Stop(); _debounceTimer.Stop(); } }