diff --git a/.gitignore b/.gitignore index 551f5be..99de91a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ appsettings.json # Publish publish/ +# Publish Profiles +Properties/PublishProfiles/ + # OS Thumbs.db .DS_Store diff --git a/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Properties/PublishProfiles/ClickOnceProfile.pubxml deleted file mode 100644 index 2f0f0d5..0000000 --- a/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - 0 - 1.0.0.* - True - Release - False - true - True - Disk - True - False - True - False - Any CPU - bin\Release\net8.0-windows\win-x86\app.publish\ - D:\Publish\UtopiaCanteenSystem\ - ClickOnce - False - False - win-x86 - True - (none) - False - false - net8.0-windows - False - Foreground - False - Publish.html - - \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml deleted file mode 100644 index 4fe46ef..0000000 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Release - Any CPU - D:\ - FileSystem - <_TargetId>Folder - net8.0-windows - win-x86 - true - false - false - - \ No newline at end of file diff --git a/Services/RfidService.cs b/Services/RfidService.cs index c3394d0..805fda3 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -42,6 +42,159 @@ public class RfidService : IRfidService return (result.Success, result.Message); } + //public ScanResult ProcessScanDetailed(string cardId) + //{ + // if (string.IsNullOrWhiteSpace(cardId)) + // return new ScanResult(false, "Card ID cannot be empty.", 0); + + // cardId = cardId.Trim(); + + // // HRMS lookup: reject if card not registered + // var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult(); + // if (employee == null) + // return new ScanResult(false, "Card not registered in HRMS.", 0); + + // var nowUtc = DateTime.UtcNow; + // var nowLocal = DateTime.Now; + + // // Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session. + // var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) + // ? employee.LocationSiteId.Trim() + // : _configService.GetSiteId(); + // //var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); + // //if (session == MealSession.None) + // // return new ScanResult(false, "This scan is outside of valid meal timings.", 0); + + // //var sessionCode = (int)session; + + // var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); + // if (resolvedSession == null || resolvedSession.Session == MealSession.None) + // return new ScanResult(false, "This scan is outside of valid meal timings.", 0); + + // var session = resolvedSession.Session; + // var sessionCode = (int)session; + + // using var db = _dbFactory.CreateDbContext(); + + // // Once-per-session-per-day rule: same card, same session, same local day is not allowed. + // var startOfTodayLocal = DateTime.Today; + // var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); + // var startUtc = startOfTodayLocal.ToUniversalTime(); + // var endUtc = startOfTomorrowLocal.ToUniversalTime(); + + // var alreadyScannedThisSessionToday = db.LunchOrderTransactions + // .Where(r => r.CardId == cardId) + // .Where(r => r.MealSessionCode == sessionCode) + // .Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc) + // .OrderByDescending(r => r.ScanTime) + // .FirstOrDefault(); + + // //if (alreadyScannedThisSessionToday != null) + // //{ + // // var sessionName = GetMealSessionDisplayName(session); + // // return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0); + // //} + // if (alreadyScannedThisSessionToday != null) + // { + // //var sessionName = session.ToString(); + // var sessionName = resolvedSession.MealName; + // return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0); + // } + + // var interval = _configService.GetScanInterval(); + // var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds); + + // var windowStart = nowUtc.AddSeconds(-timeoutSeconds); + + // // Safety debounce: short cooldown per card to prevent accidental double-tap. + // var lastInWindow = db.LunchOrderTransactions + // .Where(r => r.CardId == cardId) + // .Where(r => r.ScanTime >= windowStart) + // .OrderByDescending(r => r.ScanTime) + // .FirstOrDefault(); + + // if (lastInWindow != null) + // { + // var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds); + // return new ScanResult( + // false, + // $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", + // remaining); + // } + + // var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + // var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) + // ? employee.LocationSiteId.Trim() + // : _configService.GetSiteId(); + + // // Resolve menu items for this scan (store on record so order history shows actual items) + // //var mealLabel = GetMealSessionDisplayName(session); + // //var mealLabel = session.ToString(); + // var mealLabel = resolvedSession.MealName; + // var mealItemsDisplay = string.Empty; + // double totalPrice = 0; + // try + // { + // if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumeric) && siteNumeric > 0) + // { + // var menuItems = _menuLookup + // .GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date) + // .GetAwaiter() + // .GetResult(); + + // //var matching = menuItems + // // .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) + // // .ToList(); + // var matching = menuItems.ToList(); + + // var names = matching + // .Select(i => i.ItemName) + // .Where(n => !string.IsNullOrWhiteSpace(n)) + // .Distinct() + // .ToList(); + + // if (names.Count > 0) + // { + // mealItemsDisplay = string.Join(" + ", names); + // totalPrice = matching.Sum(i => (double)i.Price); + // } + // } + // } + // catch (Exception ex) + // { + // Logger.Log(ex, "RfidService.ProcessScanDetailed menu lookup"); + // } + + // var record = new ScanRecord + // { + // CardId = cardId, + // ScanTime = nowUtc, + // IsSynced = false, + // SiteId = siteId, + // DeviceId = _configService.GetDeviceId(), + // IpAddress = GetLocalIpAddress(), + // MealSessionCode = sessionCode, + // ParentDocumentId = employee.ParentDocumentId ?? string.Empty, + // EmployeeId = employee.EmployeeId ?? string.Empty, + // UindSerial = employee.UindSerial ?? string.Empty, + // FunctionId = employee.FunctionId, + // DepartmentId = employee.DepartmentId, + // TagCreatedAtUtc = employee.TagCreatedAtUtc, + // TagCreatedBy = employee.TagCreatedBy ?? string.Empty, + // EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName, + // Department = employee.DepartmentTitle ?? string.Empty, + // DepartmentType = employee.DepartmentType ?? string.Empty, + // MealLabel = mealLabel, + // MealItems = mealItemsDisplay, + // TotalPrice = totalPrice + // }; + // db.LunchOrderTransactions.Add(record); + // db.SaveChanges(); + + // return new ScanResult(true, "Order recorded successfully.", 0, employee, session); + //} + + public ScanResult ProcessScanDetailed(string cardId) { if (string.IsNullOrWhiteSpace(cardId)) @@ -57,19 +210,61 @@ public class RfidService : IRfidService var nowUtc = DateTime.UtcNow; var nowLocal = DateTime.Now; - // Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session. + // SITE VERIFICATION: Check if employee is assigned to this site + var currentSiteId = _configService.GetSiteId(); + var normalizedCurrentSite = NormalizeSiteId(currentSiteId); + + var employeeSiteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) + ? employee.LocationSiteId.Trim() + : string.Empty; + var normalizedEmployeeSite = NormalizeSiteId(employeeSiteId); + + // If employee has a site assigned, verify it matches the current system + if (!string.IsNullOrWhiteSpace(normalizedEmployeeSite)) + { + if (!string.Equals(normalizedEmployeeSite, normalizedCurrentSite, StringComparison.OrdinalIgnoreCase)) + { + var message = $"Employee from site {normalizedEmployeeSite} is not allowed to scan here. " + + $"Only employees from site {normalizedCurrentSite} can scan."; + return new ScanResult( + false, + message, + 0, + employee, // Include employee info so UI can show who tried to scan + MealSession.None, + normalizedEmployeeSite, + normalizedCurrentSite); + } + } + else + { + // Employee has no site assigned - log warning but allow scan + // You can change this to block if required by commenting out the next line + Logger.Log( + new Exception($"Site Validation - Card: {cardId}, Employee Site: '{employeeSiteId}', Config Site: '{currentSiteId}'"), + "RfidService"); + + // If you want to BLOCK employees with no site, uncomment the following: + /* + return new ScanResult( + false, + "Employee has no site assignment. Please contact administrator.", + 0, + employee, + MealSession.None, + "UNASSIGNED", + normalizedCurrentSite); + */ + } + + // Continue with meal session validation var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); - //var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); - //if (session == MealSession.None) - // return new ScanResult(false, "This scan is outside of valid meal timings.", 0); - - //var sessionCode = (int)session; var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); if (resolvedSession == null || resolvedSession.Session == MealSession.None) - return new ScanResult(false, "This scan is outside of valid meal timings.", 0); + return new ScanResult(false, "This scan is outside of valid meal timings.", 0, employee); var session = resolvedSession.Session; var sessionCode = (int)session; @@ -89,16 +284,10 @@ public class RfidService : IRfidService .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); - //if (alreadyScannedThisSessionToday != null) - //{ - // var sessionName = GetMealSessionDisplayName(session); - // return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0); - //} if (alreadyScannedThisSessionToday != null) { - //var sessionName = session.ToString(); var sessionName = resolvedSession.MealName; - return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0); + return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session); } var interval = _configService.GetScanInterval(); @@ -119,7 +308,9 @@ public class RfidService : IRfidService return new ScanResult( false, $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", - remaining); + remaining, + employee, + session); } var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); @@ -127,9 +318,7 @@ public class RfidService : IRfidService ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); - // Resolve menu items for this scan (store on record so order history shows actual items) - //var mealLabel = GetMealSessionDisplayName(session); - //var mealLabel = session.ToString(); + // Resolve menu items for this scan var mealLabel = resolvedSession.MealName; var mealItemsDisplay = string.Empty; double totalPrice = 0; @@ -142,9 +331,6 @@ public class RfidService : IRfidService .GetAwaiter() .GetResult(); - //var matching = menuItems - // .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) - // .ToList(); var matching = menuItems.ToList(); var names = matching @@ -191,7 +377,29 @@ public class RfidService : IRfidService db.LunchOrderTransactions.Add(record); db.SaveChanges(); - return new ScanResult(true, "Order recorded successfully.", 0, employee, session); + return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite); + } + + /// + /// Normalizes a site ID to a consistent format for comparison + /// Handles formats like "1", "01", "SITE : 1", "Site 01", etc. + /// + private static string NormalizeSiteId(string? siteId) + { + if (string.IsNullOrWhiteSpace(siteId)) + return string.Empty; + + // Extract digits only + var digits = new string(siteId.Where(char.IsDigit).ToArray()); + + if (string.IsNullOrEmpty(digits)) + return siteId.Trim(); // Return original if no digits + + // Pad to 2 digits if it's a single digit + if (digits.Length == 1) + return digits.PadLeft(2, '0'); + + return digits; } //private static string GetMealSessionDisplayName(MealSession session) diff --git a/Services/ScanResult.cs b/Services/ScanResult.cs index 809d9d9..38e949f 100644 --- a/Services/ScanResult.cs +++ b/Services/ScanResult.cs @@ -12,5 +12,7 @@ public readonly record struct ScanResult( string Message, int CooldownSecondsRemaining, HrmsEmployeeInfo? EmployeeInfo = null, - MealSession MealSession = MealSession.None); + MealSession MealSession = MealSession.None, + string? EmployeeSiteId = null, + string? CurrentSiteId = null); diff --git a/ViewModels/MealSchedulesViewModel.cs b/ViewModels/MealSchedulesViewModel.cs index e867893..e3b6692 100644 --- a/ViewModels/MealSchedulesViewModel.cs +++ b/ViewModels/MealSchedulesViewModel.cs @@ -296,15 +296,32 @@ public partial class MealSchedulesViewModel : ObservableObject { ApplyFilter(); } + + private void ApplyFilter() +{ + // Remove "SITE : " from the config SiteId and normalize it (pad with leading zeros if necessary) + var site = string.IsNullOrWhiteSpace(_currentSiteId) + ? "01" + : _currentSiteId.Replace("SITE : ", "").PadLeft(2, '0'); // Normalize to two digits if empty or contains "SITE : " - private void ApplyFilter() - { - var site = string.IsNullOrWhiteSpace(_currentSiteId) ? "01" : _currentSiteId; - var filtered = _allSchedules - .Where(s => string.Equals(s.LocationSiteId?.Trim(), site, StringComparison.OrdinalIgnoreCase)) - .ToList(); - Schedules = new ObservableCollection(filtered); - } + // Log the site ID we're filtering by (for debugging purposes). + Console.WriteLine($"Filtering for site: {site}"); + + // Filter the meal schedules by the normalized site ID. + var filtered = _allSchedules + .Where(s => + { + // Normalize the LocationSiteId from the schedule to ensure consistency. + var normalizedLocationSiteId = s.LocationSiteId?.Trim().PadLeft(2, '0') ?? "00"; + Console.WriteLine($"Checking: {normalizedLocationSiteId} vs {site}"); + + return string.Equals(normalizedLocationSiteId, site, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + // Update the UI with the filtered results. + Schedules = new ObservableCollection(filtered); +} private async Task LoadSchedulesAsync() { diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index cc4cccd..d92c067 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -485,6 +485,77 @@ public partial class ScannerDashboardViewModel : ObservableObject _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() { @@ -511,10 +582,34 @@ public partial class ScannerDashboardViewModel : ObservableObject 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) { - // Requirement: when blocked due to cooldown, keep the scanned ID visible - // so users understand which card was blocked. + // Keep the scanned ID visible during cooldown CardIdInput = cardId; StartCooldownCountdown(result.CooldownSecondsRemaining); return; @@ -533,10 +628,8 @@ public partial class ScannerDashboardViewModel : ObservableObject 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) + + // Load employee photo _ = LoadEmployeePhotoAsync(info.ParentDocumentId); } // Refresh dashboard stats and order history