diff --git a/App.xaml.cs b/App.xaml.cs index 55142af..5595912 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -40,6 +40,7 @@ public partial class App : Application var configService = new ConfigService(); var employeeLookupService = new EmployeeLookupService(configService); + var employeePhotoService = new EmployeePhotoService(configService); var menuLookupService = new MenuLookupService(configService); var mealScheduleService = new ProductionMealScheduleService(configService); var mealSessionResolver = new DbMealSessionResolver(mealScheduleService); @@ -55,7 +56,7 @@ public partial class App : Application navigationService = new NavigationService( session, () => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService), - () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService), + () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService, employeePhotoService), () => new MainDashboardViewModel(navigationService, rfidService, configService, session), () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService), () => new SettingsViewModel(configService, navigationService, adminAuditService, syncService), diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index fb50f4c..f5294fe 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -163,6 +163,13 @@ public class AppDbContext : DbContext cmd.ExecuteNonQuery(); } + if (columns.Count > 0 && !columns.Contains("ParentDocumentId", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN ParentDocumentId TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + if (columns.Count > 0 && !columns.Contains("EmployeeId", StringComparer.OrdinalIgnoreCase)) { using var cmd = conn.CreateCommand(); diff --git a/Models/HrmsEmployeeInfo.cs b/Models/HrmsEmployeeInfo.cs index 7d64b3c..cde2f73 100644 --- a/Models/HrmsEmployeeInfo.cs +++ b/Models/HrmsEmployeeInfo.cs @@ -5,6 +5,9 @@ namespace UtopiaCanteenSystem.Models; /// public class HrmsEmployeeInfo { + /// Employee document id (employee.id); used to fetch photo from hrms.employee_photo. + public string ParentDocumentId { get; set; } = string.Empty; + /// Employee serial number (employee.serial_number). public string EmployeeId { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string MiddleName { get; set; } = string.Empty; diff --git a/Models/OrderHistoryItem.cs b/Models/OrderHistoryItem.cs index 551925a..ae1d2a8 100644 --- a/Models/OrderHistoryItem.cs +++ b/Models/OrderHistoryItem.cs @@ -1,3 +1,5 @@ +using System.Windows.Media; + namespace UtopiaCanteenSystem.Models; /// @@ -5,6 +7,8 @@ namespace UtopiaCanteenSystem.Models; /// public class OrderHistoryItem { + /// HRMS employee document id (employee.id) for this order; used to load photo. + public string ParentDocumentId { get; set; } = string.Empty; public string EmployeeId { get; set; } = string.Empty; public string EmployeeName { get; set; } = string.Empty; public string Department { get; set; } = string.Empty; @@ -18,6 +22,8 @@ public class OrderHistoryItem public string RelativeDateLabel { get; set; } = string.Empty; /// Time only, e.g. "09:54 AM". public string TimeDisplay { get; set; } = string.Empty; - /// Optional avatar path; null = show placeholder. - public string? AvatarPath { get; set; } + /// Optional employee photo for this row; null = show placeholder. + public ImageSource? EmployeePhoto { get; set; } + /// True when EmployeePhoto is not null. + public bool HasEmployeePhoto => EmployeePhoto != null; } diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs index c33030b..9cd1577 100644 --- a/Models/ScanRecord.cs +++ b/Models/ScanRecord.cs @@ -18,6 +18,8 @@ public class ScanRecord public string DeviceId { get; set; } = string.Empty; /// IP address of the device at scan time. public string IpAddress { get; set; } = string.Empty; + /// HRMS employee document id (employee.id) at scan time; used to load photo from hrms.employee_photo. + public string ParentDocumentId { get; set; } = string.Empty; /// HRMS employee ID (serial_number) at scan time, when lookup succeeded. public string EmployeeId { get; set; } = string.Empty; /// Full name (first + middle) from HRMS at scan time, when lookup succeeded. diff --git a/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Properties/PublishProfiles/ClickOnceProfile.pubxml index 83446e0..e5fcd31 100644 --- a/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ b/Properties/PublishProfiles/ClickOnceProfile.pubxml @@ -2,7 +2,7 @@ - 11 + 13 1.0.0.* True Release @@ -33,6 +33,6 @@ False \\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\ Publish.html - True|2026-02-12T06:25:40.9881813Z||;True|2026-02-12T11:22:02.6537306+05:00||;True|2026-02-12T10:57:16.2775876+05:00||;True|2026-02-12T10:50:58.6968682+05:00||;False|2026-02-12T10:49:27.1867616+05:00||;True|2026-02-12T10:44:55.6022990+05:00||;True|2026-02-11T17:00:04.2786466+05:00||;True|2026-02-11T16:54:37.5052808+05:00||;True|2026-02-11T16:39:26.1892892+05:00||;True|2026-02-11T16:30:57.2420414+05:00||;True|2026-02-11T16:20:44.4749529+05:00||; + False|2026-03-03T06:08:36.2813025Z||;False|2026-03-03T11:07:21.6156279+05:00||;True|2026-02-18T16:44:11.3509970+05:00||;True|2026-02-12T11:25:40.9881813+05:00||;True|2026-02-12T11:22:02.6537306+05:00||;True|2026-02-12T10:57:16.2775876+05:00||;True|2026-02-12T10:50:58.6968682+05:00||;False|2026-02-12T10:49:27.1867616+05:00||;True|2026-02-12T10:44:55.6022990+05:00||;True|2026-02-11T17:00:04.2786466+05:00||;True|2026-02-11T16:54:37.5052808+05:00||;True|2026-02-11T16:39:26.1892892+05:00||;True|2026-02-11T16:30:57.2420414+05:00||;True|2026-02-11T16:20:44.4749529+05:00||; \ No newline at end of file diff --git a/Services/EmployeeLookupService.cs b/Services/EmployeeLookupService.cs index 4b613f0..4cee5b1 100644 --- a/Services/EmployeeLookupService.cs +++ b/Services/EmployeeLookupService.cs @@ -26,9 +26,10 @@ public class EmployeeLookupService : IEmployeeLookupService const string sql = @" SELECT + e.id AS parent_document_id, e.serial_number AS employee_id, - e.first_name, - e.middle_name, + e.concatenated_name AS first_name, + '' AS middle_name, e.department_id, d.title AS department_title, d.department_type, @@ -51,13 +52,14 @@ public class EmployeeLookupService : IEmployeeLookupService return new HrmsEmployeeInfo { - EmployeeId = GetString(reader, 0), - FirstName = GetString(reader, 1), - MiddleName = GetString(reader, 2), - DepartmentId = GetString(reader, 3), - DepartmentTitle = GetString(reader, 4), - DepartmentType = GetString(reader, 5), - LocationSiteId = GetString(reader, 6) + ParentDocumentId = GetString(reader, 0), + EmployeeId = GetString(reader, 1), + FirstName = GetString(reader, 2), + MiddleName = GetString(reader, 3), + DepartmentId = GetString(reader, 4), + DepartmentTitle = GetString(reader, 5), + DepartmentType = GetString(reader, 6), + LocationSiteId = GetString(reader, 7) }; } diff --git a/Services/EmployeePhotoService.cs b/Services/EmployeePhotoService.cs new file mode 100644 index 0000000..4272d80 --- /dev/null +++ b/Services/EmployeePhotoService.cs @@ -0,0 +1,114 @@ +using System.Collections.Concurrent; +using System.Text; +using MySqlConnector; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Fetches employee photo from hrms.employee_photo by parent_document_id (employee document id from lookup). +/// Handles data URI (data:image/...;base64,...) or raw image bytes. Caches by key to avoid repeated DB calls. +/// +public class EmployeePhotoService : IEmployeePhotoService +{ + private readonly IConfigService _configService; + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private const int MaxCacheEntries = 50; + + public EmployeePhotoService(IConfigService configService) + { + _configService = configService; + } + + /// + public async Task GetPhotoBytesAsync(string parentDocumentId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(parentDocumentId)) + return null; + + var key = parentDocumentId.Trim(); + if (_cache.TryGetValue(key, out var cached)) + return cached; + + var connectionString = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(connectionString)) + return null; + + try + { + await using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + + // employee_photo.employee_id stores the employee document id (parent_document_id from employee lookup) + const string sql = "SELECT photo_blob FROM hrms.employee_photo WHERE employee_id = @parentDocumentId LIMIT 1"; + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@parentDocumentId", key); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + return null; + + if (reader.IsDBNull(0)) + return null; + + byte[] blob; + try + { + var len = reader.GetBytes(0, 0, null, 0, 0); + blob = new byte[len]; + reader.GetBytes(0, 0, blob, 0, (int)len); + } + catch + { + return null; + } + + var imageBytes = DecodeBlobToImageBytes(blob); + if (imageBytes == null || imageBytes.Length == 0) + return null; + + // Cache (evict old if needed) + while (_cache.Count >= MaxCacheEntries && _cache.Count > 0) + { + var first = _cache.Keys.FirstOrDefault(); + if (first != null) + _cache.TryRemove(first, out _); + else + break; + } + _cache[key] = imageBytes; + return imageBytes; + } + catch + { + return null; + } + } + + /// + /// Converts blob to image bytes: if blob is UTF-8 "data:image...;base64,<payload>", decodes base64; else treats as raw image. + /// + internal static byte[]? DecodeBlobToImageBytes(byte[] blob) + { + if (blob == null || blob.Length == 0) + return null; + + try + { + var str = Encoding.UTF8.GetString(blob); + if (str.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) + { + var commaIndex = str.IndexOf(','); + if (commaIndex < 0) + return null; + var base64 = str.Substring(commaIndex + 1).Trim(); + return Convert.FromBase64String(base64); + } + + return blob; + } + catch + { + return null; + } + } +} diff --git a/Services/IEmployeePhotoService.cs b/Services/IEmployeePhotoService.cs new file mode 100644 index 0000000..2014f35 --- /dev/null +++ b/Services/IEmployeePhotoService.cs @@ -0,0 +1,14 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Fetches employee profile photo from hrms.employee_photo by parent_document_id (employee document id from RFID lookup). +/// Photo blob may be a data URI (data:image/...;base64,...) or raw image bytes. +/// +public interface IEmployeePhotoService +{ + /// + /// Gets photo bytes for the given parent_document_id (employee.id from lookup), or null if not found / decode failed. + /// May return cached bytes for the same key. + /// + Task GetPhotoBytesAsync(string parentDocumentId, CancellationToken cancellationToken = default); +} diff --git a/Services/RfidService.cs b/Services/RfidService.cs index 276f377..b2ccc40 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -108,6 +108,7 @@ public class RfidService : IRfidService SiteId = siteId, DeviceId = _configService.GetDeviceId(), IpAddress = GetLocalIpAddress(), + ParentDocumentId = employee.ParentDocumentId ?? string.Empty, EmployeeId = employee.EmployeeId ?? string.Empty, EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName, Department = employee.DepartmentTitle ?? string.Empty, diff --git a/ViewModels/MealSchedulesViewModel.cs b/ViewModels/MealSchedulesViewModel.cs index 6f56c0b..fe4333e 100644 --- a/ViewModels/MealSchedulesViewModel.cs +++ b/ViewModels/MealSchedulesViewModel.cs @@ -195,10 +195,18 @@ public partial class MealSchedulesViewModel : ObservableObject } } + /// Selects a schedule for editing (used by Actions column Edit button). [RelayCommand] - private async Task Delete() + private void SelectSchedule(MealSchedule? schedule) { - if (SelectedSchedule == null) + SelectedSchedule = schedule; + } + + [RelayCommand] + private async Task Delete(MealSchedule? row) + { + var toDelete = row ?? SelectedSchedule; + if (toDelete == null) { Message = "Select a schedule to delete."; IsError = true; @@ -208,7 +216,7 @@ public partial class MealSchedulesViewModel : ObservableObject IsError = false; try { - await _mealScheduleService.DeleteAsync(SelectedSchedule.Id).ConfigureAwait(true); + await _mealScheduleService.DeleteAsync(toDelete.Id).ConfigureAwait(true); Message = "Schedule deleted from production."; SelectedSchedule = null; await LoadSchedulesAsync().ConfigureAwait(true); diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index 43cfb69..a249a74 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -5,6 +5,8 @@ using System.Text; using DebounceTimer = System.Timers.Timer; using Microsoft.Win32; using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -138,6 +140,13 @@ public partial class ScannerDashboardViewModel : ObservableObject [ObservableProperty] private string? _employeeProfileImagePath; + /// Employee photo from hrms.employee_photo (in-memory); null = show placeholder. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasEmployeeProfileImage))] + private ImageSource? _employeeProfileImage; + + public bool HasEmployeeProfileImage => EmployeeProfileImage != null; + /// Last 4 orders for the Order History card (newest first). public ObservableCollection OrderHistory { get; } = new(); @@ -154,19 +163,22 @@ public partial class ScannerDashboardViewModel : ObservableObject private bool _isOrderHistoryModalOpen; private readonly IMenuLookupService _menuLookupService; + private readonly IEmployeePhotoService _employeePhotoService; public ScannerDashboardViewModel( IRfidService rfidService, INavigationService navigation, AppSession session, IConfigService configService, - IMenuLookupService menuLookupService) + IMenuLookupService menuLookupService, + IEmployeePhotoService employeePhotoService) { _rfidService = rfidService; _navigation = navigation; _session = session; _configService = configService; _menuLookupService = menuLookupService; + _employeePhotoService = employeePhotoService; _uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; // Debounce auto-submit (keyboard wedge scanners). @@ -503,6 +515,8 @@ public partial class ScannerDashboardViewModel : ObservableObject // 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); @@ -575,6 +589,7 @@ public partial class ScannerDashboardViewModel : ObservableObject EmployeeDepartment = "—"; EmployeeDepartmentType = "—"; EmployeeOrderItem = "—"; + EmployeeProfileImage = null; return; } @@ -591,6 +606,53 @@ public partial class ScannerDashboardViewModel : ObservableObject EmployeeOrderItem = last.MealItems; else EmployeeOrderItem = "—"; + // Load photo for last scanned employee (e.g. on app restart) using parent_document_id + _ = LoadEmployeePhotoAsync(string.IsNullOrWhiteSpace(last.ParentDocumentId) ? null : last.ParentDocumentId); + } + + /// + /// Loads employee photo from hrms.employee_photo by parent_document_id and sets EmployeeProfileImage on the UI thread. + /// Fails gracefully: no photo or decode error leaves placeholder (null). + /// + private async Task LoadEmployeePhotoAsync(string? parentDocumentId) + { + if (string.IsNullOrWhiteSpace(parentDocumentId)) + { + _uiDispatcher.Invoke(() => EmployeeProfileImage = null); + return; + } + + try + { + var bytes = await _employeePhotoService.GetPhotoBytesAsync(parentDocumentId.Trim()).ConfigureAwait(false); + _uiDispatcher.Invoke(() => + { + if (bytes == null || bytes.Length == 0) + { + EmployeeProfileImage = null; + return; + } + try + { + using var ms = new MemoryStream(bytes); + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.StreamSource = ms; + bmp.EndInit(); + bmp.Freeze(); + EmployeeProfileImage = bmp; + } + catch + { + EmployeeProfileImage = null; + } + }); + } + catch + { + _uiDispatcher.Invoke(() => EmployeeProfileImage = null); + } } private void LoadOrderHistory() @@ -602,9 +664,11 @@ public partial class ScannerDashboardViewModel : ObservableObject var local = r.ScanTime.ToLocalTime(); var employeeId = !string.IsNullOrWhiteSpace(r.EmployeeId) ? r.EmployeeId : "—"; var employeeName = !string.IsNullOrWhiteSpace(r.EmployeeName) ? r.EmployeeName : "—"; + var photo = CreateImageSourceFromPhoto(r.ParentDocumentId); OrderHistory.Add(new OrderHistoryItem { + ParentDocumentId = r.ParentDocumentId ?? string.Empty, EmployeeId = employeeId, EmployeeName = employeeName, Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, @@ -615,7 +679,8 @@ public partial class ScannerDashboardViewModel : ObservableObject : (!string.IsNullOrWhiteSpace(r.MealLabel) ? r.MealLabel : "—"), TotalPrice = r.TotalPrice > 0 ? r.TotalPrice : 0, TimeDisplay = local.ToString("hh:mm tt"), - RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) + RelativeDateLabel = GetRelativeDateLabel(r.ScanTime), + EmployeePhoto = photo }); } @@ -631,9 +696,11 @@ public partial class ScannerDashboardViewModel : ObservableObject var local = r.ScanTime.ToLocalTime(); var employeeId = !string.IsNullOrWhiteSpace(r.EmployeeId) ? r.EmployeeId : "—"; var employeeName = !string.IsNullOrWhiteSpace(r.EmployeeName) ? r.EmployeeName : "—"; + var photo = CreateImageSourceFromPhoto(r.ParentDocumentId); TodayOrderHistory.Add(new OrderHistoryItem { + ParentDocumentId = r.ParentDocumentId ?? string.Empty, EmployeeId = employeeId, EmployeeName = employeeName, Department = string.IsNullOrWhiteSpace(r.Department) ? "—" : r.Department, @@ -644,11 +711,45 @@ public partial class ScannerDashboardViewModel : ObservableObject : (!string.IsNullOrWhiteSpace(r.MealLabel) ? r.MealLabel : "—"), TotalPrice = r.TotalPrice > 0 ? r.TotalPrice : 0, TimeDisplay = local.ToString("hh:mm tt"), - RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) + RelativeDateLabel = GetRelativeDateLabel(r.ScanTime), + EmployeePhoto = photo }); } } + /// + /// Helper to synchronously create an ImageSource for order history rows using cached employee photos. + /// Returns null on any failure and does not throw. + /// + private ImageSource? CreateImageSourceFromPhoto(string? parentDocumentId) + { + try + { + if (string.IsNullOrWhiteSpace(parentDocumentId)) + return null; + + var bytes = _employeePhotoService.GetPhotoBytesAsync(parentDocumentId.Trim()) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + if (bytes == null || bytes.Length == 0) + return null; + + using var ms = new MemoryStream(bytes); + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.StreamSource = ms; + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } + catch + { + return null; + } + } + [RelayCommand] private void OpenOrderHistoryModal() { diff --git a/Views/MealSchedulesView.xaml b/Views/MealSchedulesView.xaml index d5fd1c7..3948fa0 100644 --- a/Views/MealSchedulesView.xaml +++ b/Views/MealSchedulesView.xaml @@ -4,114 +4,577 @@ xmlns:converters="clr-namespace:UtopiaCanteenSystem.Converters"> - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -