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,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.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();
}
}