diff --git a/App.xaml.cs b/App.xaml.cs index d60fcf3..180c5b1 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -40,7 +40,7 @@ public partial class App : Application navigationService = new NavigationService( session, () => new AdminLoginViewModel(authService, session, navigationService, configService), - () => new ScannerDashboardViewModel(rfidService, navigationService, session), + () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService), () => new MainDashboardViewModel(navigationService, rfidService, configService, session), () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService), () => new SettingsViewModel(configService, navigationService)); @@ -53,8 +53,8 @@ public partial class App : Application }; mainWindow.Show(); - // Background sync: every 3 hours, POST unsynced ScanRecords to API - _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(3).TotalMilliseconds) + // Background sync: every 15 minutes, POST unsynced ScanRecords to API + _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds) { AutoReset = true }; diff --git a/Converters/StringToVisibilityConverter.cs b/Converters/StringToVisibilityConverter.cs new file mode 100644 index 0000000..79751c2 --- /dev/null +++ b/Converters/StringToVisibilityConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace UtopiaCanteenSystem.Converters +{ + public class StringToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return string.IsNullOrWhiteSpace(value?.ToString()) + ? Visibility.Collapsed + : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 6ee25ef..bbfb880 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -1,6 +1,7 @@ +using System.Data; +using System.IO; using Microsoft.EntityFrameworkCore; using UtopiaCanteenSystem.Models; -using System.IO; namespace UtopiaCanteenSystem.Data; @@ -51,5 +52,47 @@ public class AppDbContext : DbContext public void EnsureDatabaseCreated() { Database.EnsureCreated(); + UpgradeScanRecordsSchemaIfNeeded(); + } + + /// + /// Lightweight schema upgrade: add SiteId and DeviceId to ScanRecords if missing (no EF migrations). + /// Does not delete any data. + /// + private void UpgradeScanRecordsSchemaIfNeeded() + { + try + { + var conn = Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + var columns = new List(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT name FROM pragma_table_info('ScanRecords')"; + using var r = cmd.ExecuteReader(); + while (r.Read()) + columns.Add(r.GetString(0)); + } + + if (!columns.Contains("SiteId", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE ScanRecords ADD COLUMN SiteId TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (!columns.Contains("DeviceId", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE ScanRecords ADD COLUMN DeviceId TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + } + catch + { + // Ignore; existing DB may already have columns or be incompatible + } } } diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs index 13f1b1b..decc340 100644 --- a/Models/ScanRecord.cs +++ b/Models/ScanRecord.cs @@ -12,4 +12,8 @@ public class ScanRecord public DateTime ScanTime { get; set; } /// True after record has been successfully sent to the sync API. public bool IsSynced { get; set; } + /// Site identifier where the scan occurred (from config). + public string SiteId { get; set; } = string.Empty; + /// Stable device identifier that generated the scan (from config). + public string DeviceId { get; set; } = string.Empty; } diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 1eee4ab..fce01dd 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -51,6 +51,32 @@ public class ConfigService : IConfigService SaveConfig(); } + public string GetSiteId() => _config.SiteId ?? "SITE : 1"; + + public void SetSiteId(string siteId) + { + _config.SiteId = string.IsNullOrWhiteSpace(siteId) ? "SITE : 1" : (siteId ?? string.Empty); + SaveConfig(); + } + + public string GetDeviceId() + { + var id = _config.DeviceId ?? string.Empty; + if (string.IsNullOrWhiteSpace(id)) + { + id = Guid.NewGuid().ToString("N"); + _config.DeviceId = id; + SaveConfig(); + } + return id; + } + + public void SetDeviceId(string deviceId) + { + _config.DeviceId = deviceId ?? string.Empty; + SaveConfig(); + } + public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials; public void SetRememberAdminCredentials(bool remember) @@ -159,6 +185,8 @@ public class ConfigService : IConfigService public bool ScannerConnected { get; set; } = false; public int ScanTimeoutSeconds { get; set; } = 60; public string AdminCardId { get; set; } = "ADMIN"; + public string SiteId { get; set; } = "SITE : 1"; + public string DeviceId { get; set; } = string.Empty; // Admin credential persistence (optional). public bool RememberAdminCredentials { get; set; } = false; diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs index 4ae7b39..c7854c3 100644 --- a/Services/IConfigService.cs +++ b/Services/IConfigService.cs @@ -14,6 +14,11 @@ public interface IConfigService string GetAdminCardId(); void SetAdminCardId(string cardId); + string GetSiteId(); + void SetSiteId(string siteId); + string GetDeviceId(); + void SetDeviceId(string deviceId); + bool GetRememberAdminCredentials(); void SetRememberAdminCredentials(bool remember); string GetSavedAdminUsername(); diff --git a/Services/IRfidService.cs b/Services/IRfidService.cs index c5ba6cc..3560cca 100644 --- a/Services/IRfidService.cs +++ b/Services/IRfidService.cs @@ -31,6 +31,9 @@ public interface IRfidService /// Task GetTodayScanCountAsync(CancellationToken cancellationToken = default); + /// Returns total number of scans/orders recorded (all time). + Task GetTotalScanCountAsync(CancellationToken cancellationToken = default); + /// /// Returns total number of scans recorded for a given card ID (all time). /// Used to detect repeat scans by the same user. diff --git a/Services/RfidService.cs b/Services/RfidService.cs index f63a05f..b23518a 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -56,7 +56,7 @@ public class RfidService : IRfidService var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds); return new ScanResult( false, - $"Multiple scans within {FormatTimeout(timeoutSeconds)} are not allowed. Please wait.", + $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", remaining); } @@ -71,7 +71,7 @@ public class RfidService : IRfidService var remaining = GetCooldownRemainingSeconds(nowUtc, lastAnyScanInWindow.ScanTime, timeoutSeconds); return new ScanResult( false, - $"Only one scan within {FormatTimeout(timeoutSeconds)} is allowed. Please wait.", + $"Only one order at a time within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", remaining); } @@ -79,12 +79,14 @@ public class RfidService : IRfidService { CardId = cardId, ScanTime = nowUtc, - IsSynced = false + IsSynced = false, + SiteId = _configService.GetSiteId(), + DeviceId = _configService.GetDeviceId() }; db.ScanRecords.Add(record); db.SaveChanges(); - return new ScanResult(true, "Scan recorded successfully.", 0); + return new ScanResult(true, "Order recorded successfully.", 0); } public ScanRecord? GetLastScan() @@ -124,6 +126,12 @@ public class RfidService : IRfidService .ConfigureAwait(false); } + public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await db.ScanRecords.CountAsync(cancellationToken).ConfigureAwait(false); + } + public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cardId)) diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 0abe6f6..f2151b3 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -44,10 +44,11 @@ public class SyncService : ISyncService var payload = toSync.Select(r => new { - r.Id, - r.CardId, - ScanTime = r.ScanTime, - r.IsSynced + DeviceLocalRowId = r.Id, + ScanTimeUtc = r.ScanTime, + SiteId = r.SiteId ?? string.Empty, + DeviceId = r.DeviceId ?? string.Empty, + r.CardId }).ToList(); try diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index a7a14ea..8a561fc 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -20,6 +20,7 @@ public partial class ScannerDashboardViewModel : ObservableObject private readonly IRfidService _rfidService; private readonly INavigationService _navigation; private readonly AppSession _session; + private readonly IConfigService _configService; private readonly Dispatcher _uiDispatcher; private readonly DebounceTimer _debounceTimer; @@ -55,8 +56,9 @@ public partial class ScannerDashboardViewModel : ObservableObject [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) ? "Connected" : "waiting for scan..."; + 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"); @@ -65,12 +67,22 @@ public partial class ScannerDashboardViewModel : ObservableObject [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; @@ -80,11 +92,23 @@ public partial class ScannerDashboardViewModel : ObservableObject public bool IsAdminAuthenticated => _session.IsAdminAuthenticated; - public ScannerDashboardViewModel(IRfidService rfidService, INavigationService navigation, AppSession session) + // --- 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()}"; + + 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). @@ -121,10 +145,33 @@ public partial class ScannerDashboardViewModel : ObservableObject _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(); } + 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. @@ -285,7 +332,7 @@ public partial class ScannerDashboardViewModel : ObservableObject _uiDispatcher.Invoke(() => { IsSuccess = true; - Message = $"Welcome back! You scanned again (#{todayCountForCard} today)."; + Message = $"Welcome back! Another order (#{todayCountForCard} today)."; }); } } @@ -305,6 +352,7 @@ public partial class ScannerDashboardViewModel : ObservableObject _uiDispatcher.Invoke(() => { TodaysScans = todayCount; + TotalOrders = todayCount; // Total orders = today's total only (not previous days) ApplyLastScan(last); }); } @@ -338,6 +386,7 @@ public partial class ScannerDashboardViewModel : ObservableObject _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(); } @@ -350,18 +399,23 @@ public partial class ScannerDashboardViewModel : ObservableObject _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(); - Message = "You can scan now."; + CooldownAlertMessage = string.Empty; + Message = "Ready for next order."; IsSuccess = true; return; } @@ -372,6 +426,7 @@ public partial class ScannerDashboardViewModel : ObservableObject 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; } diff --git a/Views/AdminLoginView.xaml b/Views/AdminLoginView.xaml index be7d9e5..d2d86a6 100644 --- a/Views/AdminLoginView.xaml +++ b/Views/AdminLoginView.xaml @@ -80,7 +80,7 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:UtopiaCanteenSystem.Converters"> + + @@ -10,7 +13,7 @@ - + + + + + + + + + + + + + + + + + + + + + + - - + - - + - - + + - - - diff --git a/Views/ScannerDashboardView.xaml.cs b/Views/ScannerDashboardView.xaml.cs index 0c9c6fa..b72a0a8 100644 --- a/Views/ScannerDashboardView.xaml.cs +++ b/Views/ScannerDashboardView.xaml.cs @@ -1,6 +1,8 @@ +using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Threading; using UtopiaCanteenSystem.ViewModels; @@ -51,10 +53,24 @@ public partial class ScannerDashboardView : UserControl private void RfidInputTextBox_OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) { - // Keep view scan-ready while allowing clicks (Settings/Logout) to complete first. + // If focus moved into the menu panel (e.g. Site number field), don't steal it back. + if (IsDescendantOf(MenuPanel, e.NewFocus as DependencyObject)) + return; + // Otherwise keep view scan-ready (e.g. after closing menu or clicking elsewhere). FocusRfidInput(selectAll: false, DispatcherPriority.ApplicationIdle); } + private static bool IsDescendantOf(DependencyObject? ancestor, DependencyObject? element) + { + if (ancestor == null || element == null) return false; + while (element != null) + { + if (element == ancestor) return true; + element = VisualTreeHelper.GetParent(element); + } + return false; + } + private void RfidInputTextBox_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key != Key.Enter) @@ -66,5 +82,16 @@ public partial class ScannerDashboardView : UserControl e.Handled = true; } } + + private void SiteNumberTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + e.Handled = !Regex.IsMatch(e.Text, @"^\d+$"); + } + + private void MenuToggleButton_Unchecked(object sender, RoutedEventArgs e) + { + // When user closes the menu, focus scanner input so they can scan without clicking it. + FocusRfidInput(selectAll: false, DispatcherPriority.ApplicationIdle); + } } diff --git a/Views/ScannerView.xaml b/Views/ScannerView.xaml index 9789a5f..5da67dd 100644 --- a/Views/ScannerView.xaml +++ b/Views/ScannerView.xaml @@ -155,7 +155,7 @@ - - + - - - - - - + + + + + + - + + + - - - - - + + + + + + + + + + + + - + @@ -300,7 +330,7 @@ Style="{StaticResource OutlineButtonStyle}" MinWidth="120" Margin="0,0,16,0" /> -