diff --git a/App.xaml.cs b/App.xaml.cs index 180c5b1..39c3e97 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -31,6 +31,7 @@ public partial class App : Application var configService = new ConfigService(); var rfidService = new RfidService(dbFactory, configService); var syncService = new SyncService(dbFactory, configService); + var adminAuditService = new AdminAuditService(dbFactory); var session = new AppSession(); var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/"; var authService = new AuthService(authenticationUrl); @@ -39,11 +40,11 @@ public partial class App : Application NavigationService navigationService = null!; navigationService = new NavigationService( session, - () => new AdminLoginViewModel(authService, session, navigationService, configService), + () => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService), () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService), () => new MainDashboardViewModel(navigationService, rfidService, configService, session), () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService), - () => new SettingsViewModel(configService, navigationService)); + () => new SettingsViewModel(configService, navigationService, adminAuditService)); var mainViewModel = new MainViewModel(navigationService); diff --git a/Converters/StringToImageSourceConverter.cs b/Converters/StringToImageSourceConverter.cs new file mode 100644 index 0000000..5229b74 --- /dev/null +++ b/Converters/StringToImageSourceConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media.Imaging; + +namespace UtopiaCanteenSystem.Converters +{ + /// + /// Converts a string URI (e.g. pack:// or file path) to an ImageSource for Image controls. + /// + public class StringToImageSourceConverter : IValueConverter + { + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var path = value?.ToString(); + if (string.IsNullOrWhiteSpace(path)) + return null; + + try + { + var uri = new Uri(path, UriKind.RelativeOrAbsolute); + return new BitmapImage(uri); + } + catch + { + return null; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Converters/StringToVisibilityConverter.cs b/Converters/StringToVisibilityConverter.cs index 79751c2..6885a3b 100644 --- a/Converters/StringToVisibilityConverter.cs +++ b/Converters/StringToVisibilityConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.Windows; using System.Windows.Data; @@ -9,9 +9,10 @@ namespace UtopiaCanteenSystem.Converters { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return string.IsNullOrWhiteSpace(value?.ToString()) - ? Visibility.Collapsed - : Visibility.Visible; + var visible = !string.IsNullOrWhiteSpace(value?.ToString()); + if (string.Equals(parameter?.ToString(), "Invert", StringComparison.OrdinalIgnoreCase)) + visible = !visible; + return visible ? Visibility.Visible : Visibility.Collapsed; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index bbfb880..a912085 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -17,6 +17,7 @@ public class AppDbContext : DbContext public DbSet Labour { get; set; } public DbSet ScanRecords { get; set; } + public DbSet AdminLoginRecords { get; set; } public AppDbContext() { } @@ -44,6 +45,12 @@ public class AppDbContext : DbContext e.HasIndex(x => x.IsSynced); e.HasIndex(x => x.ScanTime); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => x.LoginTimeUtc); + }); } /// @@ -53,6 +60,7 @@ public class AppDbContext : DbContext { Database.EnsureCreated(); UpgradeScanRecordsSchemaIfNeeded(); + EnsureAdminLoginTableExists(); } /// @@ -95,4 +103,30 @@ public class AppDbContext : DbContext // Ignore; existing DB may already have columns or be incompatible } } + + /// + /// Lightweight creation of AdminLoginRecords table for existing databases. + /// + private void EnsureAdminLoginTableExists() + { + try + { + var conn = Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "CREATE TABLE IF NOT EXISTS AdminLoginRecords (" + + "Id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "Username TEXT NOT NULL, " + + "EmployeeId TEXT NOT NULL, " + + "LoginTimeUtc TEXT NOT NULL)"; + cmd.ExecuteNonQuery(); + } + catch + { + // Ignore; table may already exist or DB may be read-only. + } + } } diff --git a/MainWindow.xaml b/MainWindow.xaml index 20dbf98..c9ba628 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -3,10 +3,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Utopia Canteen System" Icon="pack://siteoforigin:,,,/assets/favicon.ico" - MinHeight="600" MinWidth="800" - WindowStartupLocation="CenterScreen" - SizeToContent="WidthAndHeight"> + MinHeight="400" MinWidth="480" + Width="900" Height="700" + WindowStartupLocation="CenterScreen"> - + diff --git a/Models/AdminLoginRecord.cs b/Models/AdminLoginRecord.cs new file mode 100644 index 0000000..9505edc --- /dev/null +++ b/Models/AdminLoginRecord.cs @@ -0,0 +1,14 @@ +namespace UtopiaCanteenSystem.Models; + +/// +/// Audit record of an admin login on this kiosk/device. +/// Passwords are NEVER stored here – only identity and timestamps. +/// +public class AdminLoginRecord +{ + public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string EmployeeId { get; set; } = string.Empty; + public DateTime LoginTimeUtc { get; set; } +} + diff --git a/Services/AdminAuditService.cs b/Services/AdminAuditService.cs new file mode 100644 index 0000000..b3fa9f5 --- /dev/null +++ b/Services/AdminAuditService.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using UtopiaCanteenSystem.Data; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Persists simple admin login audit records to the local SQLite database. +/// +public class AdminAuditService : IAdminAuditService +{ + private readonly DbContextFactory _dbFactory; + + public AdminAuditService(DbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task RecordLoginAsync(string username, string employeeId) + { + try + { + await using var db = _dbFactory.CreateDbContext(); + var record = new AdminLoginRecord + { + Username = username ?? string.Empty, + EmployeeId = employeeId ?? string.Empty, + LoginTimeUtc = DateTime.UtcNow + }; + db.AdminLoginRecords.Add(record); + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch + { + // Audit failures should never block login; ignore errors. + } + } + + /// + /// Returns the most recent admin login record for this kiosk, or null if none exist. + /// + public AdminLoginRecord? GetLastLogin() + { + try + { + using var db = _dbFactory.CreateDbContext(); + return db.AdminLoginRecords + .OrderByDescending(x => x.LoginTimeUtc) + .FirstOrDefault(); + } + catch + { + return null; + } + } +} + diff --git a/Services/IAdminAuditService.cs b/Services/IAdminAuditService.cs new file mode 100644 index 0000000..8ff409b --- /dev/null +++ b/Services/IAdminAuditService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +public interface IAdminAuditService +{ + Task RecordLoginAsync(string username, string employeeId); + AdminLoginRecord? GetLastLogin(); +} + diff --git a/ViewModels/AdminLoginViewModel.cs b/ViewModels/AdminLoginViewModel.cs index be78ee7..1817e66 100644 --- a/ViewModels/AdminLoginViewModel.cs +++ b/ViewModels/AdminLoginViewModel.cs @@ -10,6 +10,7 @@ public partial class AdminLoginViewModel : ObservableObject private readonly AppSession _session; private readonly INavigationService _navigation; private readonly IConfigService _config; + private readonly IAdminAuditService _adminAudit; [ObservableProperty] private string _username = string.Empty; @@ -27,12 +28,18 @@ public partial class AdminLoginViewModel : ObservableObject [ObservableProperty] private bool _rememberCredentials; - public AdminLoginViewModel(IAuthService authService, AppSession session, INavigationService navigation, IConfigService config) + public AdminLoginViewModel( + IAuthService authService, + AppSession session, + INavigationService navigation, + IConfigService config, + IAdminAuditService adminAudit) { _authService = authService; _session = session; _navigation = navigation; _config = config; + _adminAudit = adminAudit; RememberCredentials = _config.GetRememberAdminCredentials(); if (RememberCredentials) @@ -76,6 +83,9 @@ public partial class AdminLoginViewModel : ObservableObject _session.SetAdminAuthenticated(user, result.EmployeeId); + // Persist who logged in (for audit / reporting). + await _adminAudit.RecordLoginAsync(user, result.EmployeeId).ConfigureAwait(false); + // Persist credentials only if user opted in. _config.SetRememberAdminCredentials(RememberCredentials); if (RememberCredentials) diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index 8a561fc..cfea386 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -21,6 +21,8 @@ public partial class ScannerDashboardViewModel : ObservableObject 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; @@ -31,9 +33,6 @@ public partial class ScannerDashboardViewModel : ObservableObject private string _cooldownBlockedCardId = string.Empty; private bool _suppressCooldownRewrite; - private readonly DispatcherTimer _scannerStatusTimer; - private DateTime? _lastScanActivityUtc; - private readonly DispatcherTimer _clockTimer; private readonly object _submitLock = new(); @@ -103,7 +102,29 @@ public partial class ScannerDashboardViewModel : ObservableObject /// 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) + // --- 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"; + + /// Item selected by the employee for the order (e.g. Chicken Biryani). + [ObservableProperty] + private string _employeeOrderItem = "Chicken Biryani"; + + /// Optional profile image path; null = show placeholder. + [ObservableProperty] + private string? _employeeProfileImagePath = "pack://siteoforigin:,,,/assets/emp-pic/emppic.jpeg"; + + public ScannerDashboardViewModel( + IRfidService rfidService, + INavigationService navigation, + AppSession session, + IConfigService configService) { _rfidService = rfidService; _navigation = navigation; @@ -135,7 +156,7 @@ public partial class ScannerDashboardViewModel : ObservableObject _cooldownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CooldownTickSeconds) }; _cooldownTimer.Tick += (_, _) => UpdateCooldownMessage(); - // Activity-based scanner status. + // Activity-based scanner status (keyboard-wedge style scanners). _scannerStatusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _scannerStatusTimer.Tick += (_, _) => UpdateScannerStatusFromActivity(); _scannerStatusTimer.Start(); @@ -217,17 +238,17 @@ public partial class ScannerDashboardViewModel : ObservableObject } } - // Clear any previous message once a new scan starts (outside cooldown). - if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message)) - Message = string.Empty; - - // Any incoming characters means activity. + // 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(); @@ -246,6 +267,23 @@ public partial class ScannerDashboardViewModel : ObservableObject 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() { @@ -430,22 +468,6 @@ public partial class ScannerDashboardViewModel : ObservableObject IsSuccess = false; } - private void UpdateScannerStatusFromActivity() - { - if (_lastScanActivityUtc is null) - { - if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) - ScannerStatus = "Disconnected"; - return; - } - - if (DateTime.UtcNow - _lastScanActivityUtc.Value > ScannerInactivityTimeout) - { - if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) - ScannerStatus = "Disconnected"; - } - } - ~ScannerDashboardViewModel() { _scannerStatusTimer.Stop(); diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 6b39771..be2ed97 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -11,6 +11,7 @@ public partial class SettingsViewModel : ObservableObject { private readonly IConfigService _configService; private readonly INavigationService _navigation; + private readonly IAdminAuditService _adminAudit; [ObservableProperty] private string _syncApiEndpoint = string.Empty; @@ -30,10 +31,11 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _isSaving; - public SettingsViewModel(IConfigService configService, INavigationService navigation) + public SettingsViewModel(IConfigService configService, INavigationService navigation, IAdminAuditService adminAudit) { _configService = configService; _navigation = navigation; + _adminAudit = adminAudit; LoadFromConfig(); } @@ -42,7 +44,13 @@ public partial class SettingsViewModel : ObservableObject { SyncApiEndpoint = _configService.GetSyncApiEndpoint(); ScanTimeoutSeconds = _configService.GetScanTimeoutSeconds().ToString(); - AdminCardId = _configService.GetAdminCardId(); + + // Always prefer the latest admin login from the database. + var last = _adminAudit.GetLastLogin(); + if (last != null && !string.IsNullOrWhiteSpace(last.EmployeeId)) + AdminCardId = last.EmployeeId; + else + AdminCardId = "ADMIN"; } [RelayCommand] diff --git a/Views/AdminLoginView.xaml b/Views/AdminLoginView.xaml index d2d86a6..b1802d1 100644 --- a/Views/AdminLoginView.xaml +++ b/Views/AdminLoginView.xaml @@ -51,27 +51,30 @@ - - - - - - + + + + + + + - + @@ -81,8 +84,8 @@ @@ -171,8 +174,9 @@ - - + + + diff --git a/Views/AdminSettingsAuthView.xaml b/Views/AdminSettingsAuthView.xaml index 623fec9..25ebb89 100644 --- a/Views/AdminSettingsAuthView.xaml +++ b/Views/AdminSettingsAuthView.xaml @@ -76,27 +76,30 @@ - - - - - - + + + + + + + - + @@ -147,9 +150,9 @@ - + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + - - - + + + + + + + + +