597 lines
20 KiB
C#
597 lines
20 KiB
C#
using System.Collections.ObjectModel;
|
|
using DebounceTimer = System.Timers.Timer;
|
|
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 (sample data for display; replace with real lookup when available) ---
|
|
[ObservableProperty]
|
|
private string _employeeName = "Syed Mustufa Ahmed Naqvi";
|
|
|
|
[ObservableProperty]
|
|
private string _employeeDepartment = "Technology";
|
|
|
|
[ObservableProperty]
|
|
private string _employeeId = "15399";
|
|
|
|
/// <summary>Item selected by the employee for the order (e.g. Chicken Biryani).</summary>
|
|
[ObservableProperty]
|
|
private string _employeeOrderItem = "Chicken Biryani";
|
|
|
|
/// <summary>Optional profile image path; null = show placeholder.</summary>
|
|
[ObservableProperty]
|
|
private string? _employeeProfileImagePath = "pack://siteoforigin:,,,/assets/emp-pic/emppic.jpeg";
|
|
|
|
/// <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;
|
|
|
|
public ScannerDashboardViewModel(
|
|
IRfidService rfidService,
|
|
INavigationService navigation,
|
|
AppSession session,
|
|
IConfigService configService)
|
|
{
|
|
_rfidService = rfidService;
|
|
_navigation = navigation;
|
|
_session = session;
|
|
_configService = configService;
|
|
_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();
|
|
}
|
|
|
|
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).
|
|
if (!string.IsNullOrWhiteSpace(digits))
|
|
_configService.SetSiteId("SITE : " + digits);
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Refresh dashboard info on success (record is saved).
|
|
_ = 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 = "—";
|
|
return;
|
|
}
|
|
|
|
LastCardId = last.CardId;
|
|
LastScanTimeDisplay = last.ScanTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
|
|
}
|
|
|
|
private void LoadOrderHistory()
|
|
{
|
|
var scans = _rfidService.GetLastScans(4);
|
|
OrderHistory.Clear();
|
|
foreach (var r in scans)
|
|
{
|
|
var local = r.ScanTime.ToLocalTime();
|
|
// EmployeeId: placeholder format (will be replaced with real employee lookup later)
|
|
var employeeId = string.IsNullOrEmpty(r.CardId)
|
|
? "—"
|
|
: $"EMP-{(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}";
|
|
var employeeName = string.IsNullOrEmpty(r.CardId)
|
|
? "—"
|
|
: $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}";
|
|
|
|
OrderHistory.Add(new OrderHistoryItem
|
|
{
|
|
EmployeeId = employeeId,
|
|
EmployeeName = employeeName,
|
|
Department = "—",
|
|
ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId
|
|
OrderTimeUtc = r.ScanTime,
|
|
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();
|
|
// EmployeeId: placeholder format (will be replaced with real employee lookup later)
|
|
var employeeId = string.IsNullOrEmpty(r.CardId)
|
|
? "—"
|
|
: $"EMP-{(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}";
|
|
var employeeName = string.IsNullOrEmpty(r.CardId)
|
|
? "—"
|
|
: $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}";
|
|
// Demo-only order item: rotate through a small list based on card hash
|
|
var sampleOrders = new[]
|
|
{
|
|
"Chicken Biryani",
|
|
"Veg Thali",
|
|
"Grilled Sandwich",
|
|
"Pasta Alfredo",
|
|
"Chicken Shawarma",
|
|
"Paneer Wrap"
|
|
};
|
|
var cardKey = r.CardId ?? string.Empty;
|
|
var orderIndex = sampleOrders.Length == 0
|
|
? 0
|
|
: Math.Abs(cardKey.GetHashCode()) % sampleOrders.Length;
|
|
var orderItem = sampleOrders[orderIndex];
|
|
|
|
TodayOrderHistory.Add(new OrderHistoryItem
|
|
{
|
|
EmployeeId = employeeId,
|
|
EmployeeName = employeeName,
|
|
Department = "—",
|
|
ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId
|
|
OrderTimeUtc = r.ScanTime,
|
|
OrderItem = orderItem,
|
|
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;
|
|
}
|
|
|
|
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;
|
|
CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({seconds} seconds remaining)";
|
|
UpdateCooldownMessage();
|
|
_cooldownTimer.Start();
|
|
}
|
|
|
|
private void StopCooldownCountdown()
|
|
{
|
|
_cooldownTimer.Stop();
|
|
_cooldownEndsUtc = null;
|
|
_lastDisplayedCooldownSeconds = -1;
|
|
_isCooldownActive = false;
|
|
IsCooldownActiveUi = false;
|
|
IsProcessing = false;
|
|
CooldownAlertMessage = string.Empty;
|
|
}
|
|
|
|
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;
|
|
|
|
var unit = remaining == 1 ? "second" : "seconds";
|
|
Message = $"Please wait {remaining} {unit}…";
|
|
CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({remaining} {unit} remaining)";
|
|
IsSuccess = false;
|
|
}
|
|
|
|
~ScannerDashboardViewModel()
|
|
{
|
|
_scannerStatusTimer.Stop();
|
|
_cooldownTimer.Stop();
|
|
_clockTimer.Stop();
|
|
_debounceTimer.Stop();
|
|
}
|
|
}
|
|
|