827 lines
30 KiB
C#
827 lines
30 KiB
C#
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.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using UtopiaCanteenSystem.Models;
|
|
using UtopiaCanteenSystem.Services;
|
|
|
|
namespace UtopiaCanteenSystem.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Single-screen ViewModel that combines continuous scanning + dashboard info.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>Shows scanner device connectivity: connected = ready; disconnected = check device.</summary>
|
|
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 = "—";
|
|
|
|
/// <summary>Prominent alert when customer scanned during cooldown: ask them to rescan after countdown.</summary>
|
|
[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";
|
|
|
|
/// <summary>Display string for current site (e.g. "SITE : 1").</summary>
|
|
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 = "—";
|
|
|
|
/// <summary>Department type from HRMS (e.g. displayed under name).</summary>
|
|
[ObservableProperty]
|
|
private string _employeeDepartmentType = "—";
|
|
|
|
/// <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 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;
|
|
|
|
/// <summary>Last 4 orders for the Order History card (newest first).</summary>
|
|
public ObservableCollection<OrderHistoryItem> OrderHistory { get; } = new();
|
|
|
|
/// <summary>All orders for today, for the \"View All\" modal (newest first).</summary>
|
|
public ObservableCollection<OrderHistoryItem> TodayOrderHistory { get; } = new();
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(HasOrderHistory))]
|
|
private bool _orderHistoryEmpty;
|
|
|
|
public bool HasOrderHistory => !OrderHistoryEmpty;
|
|
|
|
[ObservableProperty]
|
|
private bool _isOrderHistoryModalOpen;
|
|
|
|
private readonly IMenuLookupService _menuLookupService;
|
|
|
|
public ScannerDashboardViewModel(
|
|
IRfidService rfidService,
|
|
INavigationService navigation,
|
|
AppSession session,
|
|
IConfigService configService,
|
|
IMenuLookupService menuLookupService)
|
|
{
|
|
_rfidService = rfidService;
|
|
_navigation = navigation;
|
|
_session = session;
|
|
_configService = configService;
|
|
_menuLookupService = menuLookupService;
|
|
_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 (e.g. "SITE : 1" -> "1").
|
|
var siteId = _configService.GetSiteId();
|
|
if (!string.IsNullOrWhiteSpace(siteId) && siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var num = siteId.Substring("SITE : ".Length).Trim();
|
|
if (num.Length > 0 && num.All(char.IsDigit))
|
|
SiteNumber = num;
|
|
}
|
|
|
|
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.
|
|
/// 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)
|
|
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"
|
|
};
|
|
}
|
|
|
|
// 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 = 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);
|
|
}
|
|
// 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 = "—";
|
|
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()
|
|
{
|
|
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 : "—";
|
|
|
|
OrderHistory.Add(new OrderHistoryItem
|
|
{
|
|
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)
|
|
});
|
|
}
|
|
|
|
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 : "—";
|
|
|
|
TodayOrderHistory.Add(new OrderHistoryItem
|
|
{
|
|
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)
|
|
});
|
|
}
|
|
}
|
|
|
|
[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports today's scan records to a CSV file (opens in Excel). User chooses path via Save File dialog.
|
|
/// </summary>
|
|
[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 BuildCsvFromScanRecords(IReadOnlyList<ScanRecord> 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(EscapeCsv(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;
|
|
}
|
|
|
|
/// <summary>Formats remaining seconds as one concise phrase in the best unit (e.g. "2 days", "1 minute", "30 seconds").</summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>Single concise cooldown line for the one visible alert.</summary>
|
|
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();
|
|
}
|
|
}
|
|
|