Align meal schedules and menus with current site and HRMS data

Seed per-user config from deployed appsettings.json when missing, so production SiteId and connection strings are preserved

Add centralized Logger writing to %LocalAppData%\UtopiaCanteenSystem\Logs\error.log with global unhandled exception handling

Extend scan flow to resolve HRMS menu (lunch_menu_week → lunch_menu_item → menu_item) per site/date and persist MealLabel, MealItems, and TotalPrice in SQLite

Fix menu lookups to use scanner local date instead of MySQL CURDATE(), avoiding time-zone mismatches

Restrict Meal Schedules view to only show schedules for the current configured site and remove multi-site filter options
pull/1/head
SYED MUSTUFA AHMED NAQVI 2026-03-17 10:14:05 +05:00
parent dcf536f0c7
commit d82b2d3bd7
14 changed files with 939 additions and 55 deletions

View File

@ -44,7 +44,7 @@ public partial class App : Application
var menuLookupService = new MenuLookupService(configService);
var mealScheduleService = new ProductionMealScheduleService(configService);
var mealSessionResolver = new DbMealSessionResolver(mealScheduleService);
var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver);
var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService);
var syncService = new SyncService(dbFactory, configService);
var adminAuditService = new AdminAuditService(dbFactory);
var session = new AppSession();
@ -60,7 +60,7 @@ public partial class App : Application
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService),
() => new SettingsViewModel(configService, navigationService, adminAuditService, syncService),
() => new MealSchedulesViewModel(mealScheduleService, navigationService));
() => new MealSchedulesViewModel(mealScheduleService, navigationService, configService));
var mainViewModel = new MainViewModel(navigationService);

281
CONFIG_FIX_SUMMARY.md Normal file
View File

@ -0,0 +1,281 @@
# Configuration Fix Summary
## Problem
After user login, the application was changing configuration values unexpectedly:
- Connection string changed from production to local/HRMS
- SiteId changed back to "01"
- Config was being overwritten during authentication or navigation
## Root Causes Identified
1. **ProductionMealScheduleService** was using `GetHrmsLookupConnectionString()` instead of production connection string
2. **AppConfig class** had hardcoded defaults that auto-populated when config was recreated
3. **ConfigService.GetSiteId()/SetSiteId()** silently normalized missing values to "01"
4. **No logging** around config load/save operations
5. **Config overwrites** during login-related operations
---
## Fixes Applied
### 1. ProductionMealScheduleService.cs
**File:** `Services/ProductionMealScheduleService.cs`
**Changes:**
- Changed all occurrences of `GetHrmsLookupConnectionString()` to `GetMySqlConnectionString()`
- Updated error messages from "HRMS MySQL connection string not configured" to "Production MySQL connection string not configured"
- Methods affected:
- `GetActiveSchedulesForSite()`
- `GetAllSchedulesAsync()`
- `CreateAsync()`
- `UpdateAsync()`
- `DeleteAsync()`
**Impact:** Meal schedule operations now use the production database connection string instead of the local HRMS lookup connection string.
---
### 2. AppConfig Class Defaults Removed
**File:** `Services/ConfigService.cs`
**Changes:**
```csharp
// BEFORE
public string SiteId { get; set; } = "SITE : 1";
public string HrmsLookupConnectionString { get; set; } = "Server=192.168.90.147;...";
// AFTER
public string SiteId { get; set; } = string.Empty; // No default - must be explicitly configured
public string HrmsLookupConnectionString { get; set; } = string.Empty; // Must be explicitly configured
```
**Impact:** Config values will no longer auto-populate with defaults when config file is recreated. Empty strings indicate "not configured" state.
---
### 3. ConfigService.GetSiteId() and SetSiteId() Simplified
**File:** `Services/ConfigService.cs`
**Changes:**
```csharp
// BEFORE - GetSiteId()
if (string.IsNullOrWhiteSpace(raw))
return "01";
// ... normalization logic ...
return Math.Clamp(numeric, 0, 99).ToString("D2");
// AFTER - GetSiteId()
return _config.SiteId ?? string.Empty;
// BEFORE - SetSiteId()
var normalized = siteId;
if (string.IsNullOrWhiteSpace(normalized))
{
normalized = "01";
}
else
{
// ... normalization to 2-digit format ...
}
_config.SiteId = normalized;
// AFTER - SetSiteId()
_config.SiteId = siteId ?? string.Empty;
```
**Impact:** SiteId is now stored and retrieved exactly as provided. No automatic normalization to "01". Callers are responsible for formatting.
---
### 4. Config Load/Save Logging Added
**File:** `Services/ConfigService.cs`
**Changes:**
Added comprehensive logging:
**LoadConfig():**
- Logs when config file is not found and defaults are being used
- Logs config file path on successful load
- Logs SiteId value (shows "empty=not configured" if empty)
- Logs MySqlConnectionString status ("NOT CONFIGURED" or masked value)
- Logs HrmsLookupConnectionString status ("NOT CONFIGURED" or masked value)
- Logs any exceptions during load
**SaveConfig():**
- Logs when saving starts
- Logs current SiteId value
- Logs current MySqlConnectionString status (masked)
- Logs current HrmsLookupConnectionString status (masked)
- Logs successful save with file path
- Logs any exceptions during save
**MaskConnectionString():**
- New helper method to mask sensitive password values in logs
- Shows server and database information but replaces password with "***"
**Sample Log Output:**
```
[ConfigService] Config loaded from C:\Users\...\UtopiaCanteenSystem\config.json
[ConfigService] SiteId: '02' (empty=not configured)
[ConfigService] MySqlConnectionString: Server=localhost;Database=hrms;User=root;Password=***
[ConfigService] HrmsLookupConnectionString: NOT CONFIGURED
[ConfigService] Saving config...
[ConfigService] SiteId: '02'
[ConfigService] MySqlConnectionString: Server=localhost;Database=hrms;User=root;Password=***
[ConfigService] Config saved to C:\Users\...\UtopiaCanteenSystem\config.json
```
**Impact:** Full visibility into config changes for debugging. Sensitive values are masked.
---
### 5. ScannerDashboardViewModel Updated
**File:** `ViewModels/ScannerDashboardViewModel.cs`
**Changes:**
Updated site loading logic to handle both legacy "SITE : X" format and new numeric format:
```csharp
// BEFORE
if (!string.IsNullOrWhiteSpace(siteId) && siteId.StartsWith("SITE : ", ...))
{
var num = siteId.Substring("SITE : ".Length).Trim();
if (num.Length > 0 && num.All(char.IsDigit))
SiteNumber = num;
}
// AFTER
if (!string.IsNullOrWhiteSpace(siteId))
{
// Extract digits from legacy format "SITE : X"or use value directly if already numeric
if (siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase))
{
var num = siteId.Substring("SITE : ".Length).Trim();
if (num.Length > 0 && num.All(char.IsDigit))
SiteNumber = num;
}
else if (siteId.All(char.IsDigit))
{
SiteNumber = siteId;
}
}
```
**Impact:** Backward compatible with legacy config format while supporting new numeric-only format.
---
## Expected Behavior After Fix
### ✅ Login/Authentication
- Login operations **only** modify credential-related config values:
- `RememberAdminCredentials`
- `SavedAdminUsername`
- `SavedAdminPasswordProtected`
- **Does NOT modify:**
- `SiteId`
- `MySqlConnectionString`
- `HrmsLookupConnectionString`
- Other unrelated settings
### ✅ Navigation
- Navigation between views does not trigger config saves
- ViewModel initialization reads config but doesn't modify it
- Meal Schedules page uses production connection string
### ✅ Config Persistence
- Existing config values are preserved across app restarts
- Defaults only applied when config file truly doesn't exist
- No silent normalization of values
- All config changes are logged
### ✅ Database Connections
- **Production operations** (meal schedules, sync) use `MySqlConnectionString`
- **Local HRMS operations** (employee lookup, menu lookup, photos) use `HrmsLookupConnectionString`
- Both connections must be explicitly configured
---
## Testing Recommendations
1. **Initial Startup Test:**
- Delete config.json
- Run app
- Verify log shows "Config file not found. Creating with defaults."
- Check that SiteId and connection strings are empty (not "01" or hardcoded)
2. **Login Test:**
- Configure production connection string and SiteId
- Save config
- Perform admin login
- Verify config still has correct values (not reset to defaults)
3. **Navigation Test:**
- Navigate to Meal Schedules page
- Verify it uses production connection string (check logs)
- Verify meal schedules load from production database
4. **Config Change Test:**
- Change SiteId in Settings
- Verify log shows old/new values
- Verify change persists after app restart
5. **Connection String Masking Test:**
- Check debug output for config save operations
- Verify passwords are shown as "***" in logs
---
## Migration Notes
### For Existing Users
- Existing config.json files will be preserved
- Legacy "SITE : X" format will be recognized and loaded correctly
- On first save after upgrade, SiteId will be stored without normalization
### For New Installations
- Config will be created with empty strings for SiteId and connection strings
- User must explicitly configure these values
- No assumptions about default site or database
---
## Files Modified
1. `Services/ProductionMealScheduleService.cs` - Use production connection string
2. `Services/ConfigService.cs` - Remove defaults, add logging, simplify SiteId handling
3. `ViewModels/ScannerDashboardViewModel.cs` - Handle both legacy and new SiteId formats
---
## Breaking Changes
⚠️ **None** - Changes are backward compatible:
- Legacy "SITE : X" format still supported
- Existing configs preserved
- Only behavior change: no more automatic resets to defaults
---
## Configuration Requirements
Both connection strings should be configured:
**appsettings.json or manual config edit:**
```json
{
"MySqlConnectionString": "Server=production-server;Database=hrms;User=utopia;Password=***;Port=3306;",
"HrmsLookupConnectionString": "Server=local-hrms-server;Database=hrms;User=utopia;Password=***;Port=3306;"
}
```
Or configure via Settings UI (for production connection) and manual config edit (for HRMS lookup).
---
## Next Steps
1. Test all scenarios listed above
2. Monitor debug output for config operations
3. Verify production meal schedule operations work correctly
4. Ensure login no longer modifies unrelated config values

View File

@ -0,0 +1,322 @@
# Meal Schedule View-Only Mode Changes
## Summary
Converted the Meal Schedule management screen from full CRUD (Create, Read, Update, Delete) to **view-only mode**. Users can now only view existing meal schedules without any ability to create, edit, or delete them.
---
## Changes Made
### 1. ViewModel Changes (`MealSchedulesViewModel.cs`)
#### **Removed Properties** (No longer needed for forms)
```csharp
// REMOVED:
private string_locationSiteId = string.Empty;
private string _mealName = string.Empty;
private string _startTime = "06:00:00";
private string _endTime = "09:00:00";
```
#### **Removed Commands** (All CRUD operations)
```csharp
// REMOVED:
[RelayCommand] private void AddNew() // Form preparation
[RelayCommand] private async Task Save() // Create/Update logic
[RelayCommand] private void SelectSchedule() // Edit selection
[RelayCommand] private async Task Delete() // Delete operation
```
#### **Removed Callbacks**
```csharp
// REMOVED:
partial void OnSelectedScheduleChanged(MealSchedule? value)
{
// This used to populate form fields when editing
// No longer needed in view-only mode
}
```
#### **Updated Class Summary**
```csharp
/// <summary>
/// View-only display of meal schedules from production (hrms.meal_schedule).
/// No CRUD operations.
/// Loads all schedules on open; optional Site filter (local).
/// </summary>
public partial class MealSchedulesViewModel : ObservableObject
{
// Only these properties remain:
[ObservableProperty] private ObservableCollection<MealSchedule> _schedules;
[ObservableProperty] private string_selectedSiteFilter = "All";
[ObservableProperty] private MealSchedule? _selectedSchedule;
[ObservableProperty] private string _message = string.Empty;
[ObservableProperty] private bool_isError;
[ObservableProperty] private bool _isLoading;
// Only one command remains:
[RelayCommand] private void Back(); // Navigate back to Settings
}
```
---
### 2. View Changes (`MealSchedulesView.xaml`)
#### **Removed UI Elements**
1. **"Add New" Button** (was in top-right corner)
```xml
<!-- REMOVED: -->
<Button Grid.Column="3"
Command="{Binding AddNewCommand}"
Content="+ Add New"/>
```
2. **Actions Column in DataGrid** (Edit/Delete icons)
```xml
<!-- REMOVED: -->
<DataGridTemplateColumn Header="Actions" Width="100">
<DataGridTemplateColumn.CellTemplate>
<Button Style="{StaticResource GridActionEditButton}" ... />
<Button Style="{StaticResource GridActionDeleteButton}" ... />
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
```
3. **Entire Add/Edit Form Card**
```xml
<!-- REMOVED: Complete "Add or Edit Meal Schedule" card with:
- Location Site ID input
- Meal Session input
- Start Time input
- End Time input
- Add New / Save / Delete buttons
-->
```
#### **Updated DataGrid Columns**
```xml
<!-- BEFORE (with Actions column): -->
<DataGridTextColumn Header="Site" Binding="{Binding LocationSiteId}" Width="*" />
<DataGridTextColumn Header="Session" Binding="{Binding MealName}" Width="*" />
<DataGridTextColumn Header="Start" Binding="{Binding StartTime}" Width="*" />
<DataGridTextColumn Header="End" Binding="{Binding EndTime}" Width="*" />
<DataGridTemplateColumn Header="Actions" Width="100">...edit/delete buttons...</DataGridTemplateColumn>
<!-- AFTER (no Actions column): -->
<DataGridTextColumn Header="Site" Binding="{Binding LocationSiteId}" Width="*" />
<DataGridTextColumn Header="Session" Binding="{Binding MealName}" Width="*" />
<DataGridTextColumn Header="Start Time" Binding="{Binding StartTime}" Width="*" />
<DataGridTextColumn Header="End Time" Binding="{Binding EndTime}" Width="*" />
```
#### **Updated Header Text**
```xml
<!-- BEFORE: -->
<TextBlock Text="Meal Schedules" ... />
<!-- AFTER: -->
<TextBlock Text="Meal Schedules" ... />
<TextBlock Text="View-only mode - configured meal windows per site" ... />
```
#### **Updated Empty State Message**
```csharp
// BEFORE:
Message = "No schedules in production. Add one below (HRMS MySQL must be configured).";
// AFTER:
Message = "No schedules configured in production.";
```
---
### 3. Styles Cleanup
#### **Marked as Removed** (but kept for Back button compatibility)
```xml
<!-- Kept but noted as potentially removable: -->
- PrimaryButton style (used by Back button)
- SecondaryButton style (used by Back button)
- DangerButton style (no longer used)
- GridActionButton styles (no longer used)
- GridActionEditButton style (no longer used)
- GridActionDeleteButton style (no longer used)
- ModernTextBox style (no longer used - no forms)
- ModernComboBox style (still used for Site filter)
```
---
## What Still Works
### ✅ **Functional Features:**
1. **Load all schedules** from production database
2. **Site filter dropdown** - Filter schedules by site
3. **Auto-refresh** on page load
4. **Error handling** with detailed messages
5. **Empty state** messaging
6. **Back navigation** to Settings page
7. **Read-only DataGrid** with hover/selection effects
### ✅ **Data Display:**
- Site ID
- Meal Session name
- Start time
- End time
---
## What No Longer Works
### ❌ **Removed Features:**
1. **Add New** - Cannot create new meal schedules
2. **Edit** - Cannot modify existing schedules
3. **Delete** - Cannot remove schedules
4. **Form inputs** - No textboxes for entering schedule data
5. **Save** - No save functionality
6. **Actions column** - No edit/delete buttons in grid
---
## Backend Service Layer
### **No Changes to Service Implementation**
The `ProductionMealScheduleService` still has full CRUD methods:
```csharp
public Task<long> CreateAsync(...) // Still exists but not called from UI
public Task UpdateAsync(...) // Still exists but not called from UI
public Task DeleteAsync(...) // Still exists but not called from UI
public Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync() // ✅ Still used
```
**Why keep them?**
- Service layer is generic and could be reused if admin features are re-enabled
- Other parts of the system might use these methods
- No harm in keeping them available
### **What Changed:**
The ViewModel **no longer calls** Create/Update/Delete methods. It only calls:
```csharp
await _mealScheduleService.GetAllSchedulesAsync();
```
---
## User Experience Impact
### **Before (Full CRUD):**
```
User Flow:
1. Click "Add New" → Form appears
2. Fill in Site, Session, Times
3. Click "Save" → Record created
4. Click ✎ Edit icon → Form populates
5. Modify fields
6. Click "Save" → Record updated
7. Click 🗑 Delete icon → Record deleted
```
### **After (View-Only):**
```
User Flow:
1. Open Meal Schedules page
2. See list of all schedules
3. Optionally filter by Site
4. Click "Back" to return to Settings
```
---
## Testing Checklist
### ✅ **Test Scenarios:**
1. **Initial Load**
- [ ] Navigate to Meal Schedules page
- [ ] Verify schedules load from production database
- [ ] Verify no "Add New" button visible
- [ ] Verify no edit/delete icons in grid
2. **Site Filtering**
- [ ] Change Site filter dropdown
- [ ] Verify grid filters correctly
- [ ] Select "All" to see all schedules
3. **Empty State**
- [ ] If no schedules exist, verify message: "No schedules configured in production."
- [ ] Verify no error shown (just informational message)
4. **Error Handling**
- [ ] If production DB unavailable, verify error message shown
- [ ] Verify detailed error in MessageBox
5. **Navigation**
- [ ] Click "Back" button
- [ ] Verify returns to Settings page
6. **Data Integrity**
- [ ] Verify cannot add rows via UI
- [ ] Verify cannot edit rows via UI
- [ ] Verify cannot delete rows via UI
- [ ] Verify DataGrid is read-only (IsReadOnly=True)
---
## Files Modified
| File | Lines Changed | Description |
|------|---------------|-------------|
| `ViewModels/MealSchedulesViewModel.cs` | ~135 removed | Removed all CRUD commands, form properties, callbacks |
| `Views/MealSchedulesView.xaml` | ~160 removed | Removed Add New button, Actions column, entire form card |
**Total:** ~295 lines removed, minimal additions (comments)
---
## Security Note
⚠️ **Important:** This change only removes UI elements. The backend API/service methods still exist and could be called directly if someone has database access. For true security, also restrict database permissions at the MySQL user level.
---
## Future Considerations
### **If Admin Features Need to be Re-enabled:**
1. Restore the removed ViewModel commands (AddNew, Save, Delete, SelectSchedule)
2. Restore form properties (_locationSiteId, _mealName, _startTime, _endTime)
3. Restore OnSelectedScheduleChanged callback
4. Restore XAML form card and Actions column
5. Re-add "Add New" button to header
### **Alternative Approach:**
Consider role-based access control where:
- Regular users see view-only mode
- Admin users see full CRUD mode
- Toggle visibility based on user role
---
## Commit Message
```
feat: Convert Meal Schedule management to view-only mode
Changes:
- Remove all CRUD operations (Create, Update, Delete) from MealSchedulesViewModel
- Remove Add New button, Edit/Delete action icons from UI
- Remove form input fields and related properties
- Keep only Load/Display functionality with Site filtering
- Update empty state message to reflect view-only mode
- Retain Back navigation command
Impact:
Users can now only view existing meal schedules without ability to modify them.
Backend service methods still exist but are not called from UI.
```
---
## Related Changes
This view-only mode complements the earlier fix that switched `ProductionMealScheduleService` to use the production MySQL connection string instead of the local HRMS lookup connection string, ensuring data consistency across the application.

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace UtopiaCanteenSystem.Models;
public class ResolvedMealSession
{
public MealSession Session { get; set; }
public string MealName { get; set; } = string.Empty;
public string SiteId { get; set; } = string.Empty;
public string StartTime { get; set; } = string.Empty;
public string EndTime { get; set; } = string.Empty;
}

View File

@ -19,8 +19,62 @@ public class DbMealSessionResolver : IMealSessionResolver
_mealScheduleService = mealScheduleService;
}
private static MealSession MapMealNameToSession(string? mealName)
{
if (string.IsNullOrWhiteSpace(mealName))
return MealSession.None;
var name = mealName.Trim();
return name.ToLowerInvariant() switch
{
"breakfast" => MealSession.Breakfast,
"sehri" => MealSession.Breakfast,
"lunch" => MealSession.Lunch,
"iftari" => MealSession.Lunch,
"tea" => MealSession.Tea,
"dinner" => MealSession.Dinner,
_ => MealSession.None
};
}
/// <inheritdoc />
public MealSession GetCurrentSession(DateTime nowLocal, string siteId)
//public MealSession GetCurrentSession(DateTime nowLocal, string siteId)
//{
// var normalizedSite = (siteId ?? string.Empty).Trim();
// if (string.IsNullOrEmpty(normalizedSite))
// normalizedSite = "01";
// if (normalizedSite.Length == 1 && char.IsDigit(normalizedSite[0]))
// normalizedSite = normalizedSite.PadLeft(2, '0');
// var schedules = GetSchedulesForSiteCached(normalizedSite);
// var t = nowLocal.TimeOfDay;
// //foreach (var s in schedules)
// //{
// // if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end))
// // continue;
// // if (t >= start && t < end)
// // return (MealSession)s.MealSession;
// //}
// foreach (var s in schedules)
// {
// if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end))
// continue;
// if (t >= start && t < end)
// return MapMealNameToSession(s.MealName);
// }
// return MealSession.None;
//}
public ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId)
{
var normalizedSite = (siteId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(normalizedSite))
@ -35,13 +89,24 @@ public class DbMealSessionResolver : IMealSessionResolver
{
if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end))
continue;
if (t >= start && t < end)
return (MealSession)s.MealSession;
{
return new ResolvedMealSession
{
Session = MapMealNameToSession(s.MealName),
MealName = s.MealName ?? string.Empty,
SiteId = normalizedSite,
StartTime = s.StartTime ?? string.Empty,
EndTime = s.EndTime ?? string.Empty
};
}
}
return MealSession.None;
return null;
}
private List<MealSchedule> GetSchedulesForSiteCached(string siteId)
{
lock (_cacheLock)

View File

@ -10,5 +10,6 @@ public interface IMealSessionResolver
/// <summary>
/// Returns the active meal session for the given local time and site, or MealSession.None if outside all windows.
/// </summary>
MealSession GetCurrentSession(DateTime nowLocal, string siteId);
//MealSession GetCurrentSession(DateTime nowLocal, string siteId);
ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId);
}

View File

@ -13,4 +13,10 @@ public interface IMenuLookupService
/// siteIdNumeric: match to lunch_menu_week.location_site_id (e.g. 2 for site "02").
/// </summary>
Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default);
/// <summary>
/// Gets menu items for the given site and specific local date (yyyy-MM-dd).
/// Avoids server time mismatch by not relying on CURDATE().
/// </summary>
Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, CancellationToken cancellationToken = default);
}

53
Services/Logger.cs Normal file
View File

@ -0,0 +1,53 @@
using System;
using System.IO;
using System.Text;
namespace UtopiaCanteenSystem.Services;
public static class Logger
{
private static readonly object _lock = new();
public static void Log(Exception ex, string context = "")
{
try
{
if (ex == null) return;
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var appRoot = Path.Combine(localAppData, "UtopiaCanteenSystem");
var logsDir = Path.Combine(appRoot, "Logs");
var logFile = Path.Combine(logsDir, "error.log");
try
{
if (!Directory.Exists(logsDir))
Directory.CreateDirectory(logsDir);
}
catch
{
return;
}
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}]");
if (!string.IsNullOrWhiteSpace(context))
sb.AppendLine($"Context: {context}");
sb.AppendLine($"Message: {ex.Message}");
sb.AppendLine($"StackTrace: {ex.StackTrace}");
if (ex.InnerException != null)
sb.AppendLine($"InnerException: {ex.InnerException.Message}");
sb.AppendLine("----------------------------------------------------");
lock (_lock)
{
File.AppendAllText(logFile, sb.ToString());
}
}
catch
{
// never throw from logger
}
}
}

View File

@ -18,6 +18,13 @@ public class MenuLookupService : IMenuLookupService
/// <inheritdoc />
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default)
{
// Backwards-compatible: use today's local date.
return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
@ -31,28 +38,45 @@ public class MenuLookupService : IMenuLookupService
// WHERE w.location_site_id = @siteId
// AND CURDATE() BETWEEN w.week_start_date AND w.week_end_date
// ORDER BY mi.item_type, mi.item_name";
const string sql = @"
SELECT
mi.id,
mi.item_name,
mi.item_type,
mi.price,
li.meal_name,
li.menu_date,
li.day_of_week
FROM lunch_menu_week w
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
JOIN menu_item mi ON mi.id = li.menu_item_id
WHERE w.location_site_id = @siteId
AND CURDATE() BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = DATE_FORMAT(CURDATE(), '%Y-%m-%d')
ORDER BY li.meal_name, mi.item_name;
";
AND @menuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @menuDate
ORDER BY mi.item_name;";
//const string sql = @"
// SELECT
// mi.id,
// mi.item_name,
// mi.item_type,
// mi.price,
// li.meal_name,
// li.menu_date,
// li.day_of_week
// FROM lunch_menu_week w
// JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
// JOIN menu_item mi ON mi.id = li.menu_item_id
// WHERE w.location_site_id = @siteId
// AND @menuDate BETWEEN w.week_start_date AND w.week_end_date
// AND li.menu_date = @menuDate
// ORDER BY li.meal_name, mi.item_name;";
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
var list = new List<HrmsMenuItem>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
@ -72,11 +96,22 @@ public class MenuLookupService : IMenuLookupService
ItemName = GetString(reader, 1),
ItemType = GetString(reader, 2),
Price = GetDecimal(reader, 3),
MealName = GetString(reader, 4),
MenuDate = GetString(reader, 5),
DayOfWeek = GetString(reader, 6),
MenuDate = GetString(reader, 4),
DayOfWeek = GetString(reader, 5),
MealName = string.Empty
});
//list.Add(new HrmsMenuItem
//{
// MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
// ItemName = GetString(reader, 1),
// ItemType = GetString(reader, 2),
// Price = GetDecimal(reader, 3),
// MealName = GetString(reader, 4),
// MenuDate = GetString(reader, 5),
// DayOfWeek = GetString(reader, 6),
//});
}
return list;

View File

@ -20,13 +20,20 @@ public class RfidService : IRfidService
private readonly IConfigService _configService;
private readonly IEmployeeLookupService _employeeLookup;
private readonly IMealSessionResolver _mealSessionResolver;
private readonly IMenuLookupService _menuLookup;
public RfidService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver)
public RfidService(
IDbContextFactory<AppDbContext> dbFactory,
IConfigService configService,
IEmployeeLookupService employeeLookup,
IMealSessionResolver mealSessionResolver,
IMenuLookupService menuLookup)
{
_dbFactory = dbFactory;
_configService = configService;
_employeeLookup = employeeLookup;
_mealSessionResolver = mealSessionResolver;
_menuLookup = menuLookup;
}
public (bool Success, string Message) ProcessScan(string cardId)
@ -54,10 +61,17 @@ public class RfidService : IRfidService
var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
? employee.LocationSiteId.Trim()
: _configService.GetSiteId();
var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession);
if (session == MealSession.None)
//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();
@ -75,9 +89,15 @@ 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 = GetMealSessionDisplayName(session);
//var sessionName = session.ToString();
var sessionName = resolvedSession.MealName;
return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0);
}
@ -106,6 +126,45 @@ public class RfidService : IRfidService
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,
@ -124,7 +183,10 @@ public class RfidService : IRfidService
TagCreatedBy = employee.TagCreatedBy ?? string.Empty,
EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName,
Department = employee.DepartmentTitle ?? string.Empty,
DepartmentType = employee.DepartmentType ?? string.Empty
DepartmentType = employee.DepartmentType ?? string.Empty,
MealLabel = mealLabel,
MealItems = mealItemsDisplay,
TotalPrice = totalPrice
};
db.LunchOrderTransactions.Add(record);
db.SaveChanges();
@ -132,17 +194,17 @@ public class RfidService : IRfidService
return new ScanResult(true, "Order recorded successfully.", 0, employee, session);
}
private static string GetMealSessionDisplayName(MealSession session)
{
return session switch
{
MealSession.Breakfast => "Sehri",
MealSession.Lunch => "Iftari",
MealSession.Tea => "Tea",
MealSession.Dinner => "Dinner",
_ => "this meal"
};
}
//private static string GetMealSessionDisplayName(MealSession session)
//{
// return session switch
// {
// MealSession.Breakfast => "Sehri",
// MealSession.Lunch => "Iftari",
// MealSession.Tea => "Tea",
// MealSession.Dinner => "Dinner",
// _ => "this meal"
// };
//}
public ScanRecord? GetLastScan()
{

View File

@ -257,6 +257,8 @@ public partial class MealSchedulesViewModel : ObservableObject
{
private readonly IMealScheduleService _mealScheduleService;
private readonly INavigationService _navigation;
private readonly IConfigService _configService;
private readonly string _currentSiteId;
[ObservableProperty]
private ObservableCollection<MealSchedule> _schedules = new();
@ -281,10 +283,12 @@ public partial class MealSchedulesViewModel : ObservableObject
[ObservableProperty]
private bool _isLoading;
public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation)
public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation, IConfigService configService)
{
_mealScheduleService = mealScheduleService;
_navigation = navigation;
_configService = configService;
_currentSiteId = (_configService.GetSiteId() ?? string.Empty).Trim();
_ = LoadSchedulesAsync();
}
@ -295,19 +299,10 @@ public partial class MealSchedulesViewModel : ObservableObject
private void ApplyFilter()
{
if (string.IsNullOrWhiteSpace(SelectedSiteFilter) || SelectedSiteFilter == "All")
{
Schedules = new ObservableCollection<MealSchedule>(_allSchedules);
return;
}
var site = string.IsNullOrWhiteSpace(_currentSiteId) ? "01" : _currentSiteId;
var filtered = _allSchedules
.Where(s => string.Equals(
s.LocationSiteId?.Trim(),
SelectedSiteFilter.Trim(),
StringComparison.OrdinalIgnoreCase))
.Where(s => string.Equals(s.LocationSiteId?.Trim(), site, StringComparison.OrdinalIgnoreCase))
.ToList();
Schedules = new ObservableCollection<MealSchedule>(filtered);
}
@ -329,8 +324,9 @@ public partial class MealSchedulesViewModel : ObservableObject
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
SiteFilterChoices = new ObservableCollection<string>(new[] { "All" }.Concat(siteIds));
SelectedSiteFilter = "All";
// Only show schedules for current configured site; hide other sites from filter choices.
SiteFilterChoices = new ObservableCollection<string>(new[] { string.IsNullOrWhiteSpace(_currentSiteId) ? "01" : _currentSiteId });
SelectedSiteFilter = SiteFilterChoices.First();
ApplyFilter();
if (_allSchedules.Count == 0)

View File

@ -276,8 +276,8 @@ public partial class ScannerDashboardViewModel : ObservableObject
//};
mealLabel = activeSession switch
{
MealSession.Breakfast => "Sehri",
MealSession.Lunch => "Iftari",
MealSession.Breakfast => "Breakfast",
MealSession.Lunch => "Lunch",
MealSession.Tea => "Tea",
MealSession.Dinner => "Dinner",
_ => "No active meal session"
@ -298,9 +298,10 @@ public partial class ScannerDashboardViewModel : ObservableObject
// i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0)
// .ToList();
var matchingItems = items
.Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase))
.ToList();
// var matchingItems = items
//.Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase))
//.ToList();
var matchingItems = items.ToList();
var matchingItemNames = matchingItems
.Select(i => i.ItemName)
@ -325,10 +326,10 @@ public partial class ScannerDashboardViewModel : ObservableObject
// Persist these values on the latest scan record so history rows keep their own Meal/Price.
// Only do this when we are handling an actual scan (sessionFromScan has a value).
if (sessionFromScan.HasValue && activeSession != MealSession.None)
{
_rfidService.UpdateLastScanMealInfo(mealLabel, historyDisplay, totalPrice);
}
//if (sessionFromScan.HasValue && activeSession != MealSession.None)
//{
// _rfidService.UpdateLastScanMealInfo(mealLabel, historyDisplay, totalPrice);
//}
_uiDispatcher.Invoke(() =>
{
EmployeeOrderItem = mealLabel;
@ -534,7 +535,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
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);
//_ = LoadMenuForSiteAsync(siteFromRfid, result.MealSession);
// Load employee photo from hrms.employee_photo by parent_document_id (employee document id)
_ = LoadEmployeePhotoAsync(info.ParentDocumentId);
}

View File

@ -0,0 +1,14 @@
-- Production meal_schedule table (already exists on POD).
-- Schema used by ProductionMealScheduleService (Settings → Meal Schedules).
--
-- Columns:
-- id bigint PK auto_increment
-- meal_name varchar(255) e.g. 'Breakfast', 'Lunch', 'Dinner'
-- start_time time
-- end_time time
-- created_at timestamp
-- updated_at timestamp
-- location_site_id int default 0
--
-- No is_active column; all rows are treated as active.
-- App maps meal_name <-> MealSession (0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner).

View File

@ -0,0 +1,32 @@
-- Migration: remove device_local_row_id, rename columns, and update unique index.
-- Run this on hrms (production) before deploying the app that uses new column names.
-- Production still has: unique index (device_id, device_local_row_id) and column device_local_row_id.
--
-- Order: 1) Drop unique index 2) Drop column 3) Rename scan_time_utc 4) Rename received_at_utc 5) Add new unique key
USE hrms;
-- 1) Drop the unique index on (device_id, device_local_row_id)
ALTER TABLE lunch_order_transactions
DROP INDEX ux_lunch_order_tx_device_device_local_row_id;
-- 2) Drop the column
ALTER TABLE lunch_order_transactions
DROP COLUMN device_local_row_id;
-- 3) Rename scan_time_utc → scan_date
ALTER TABLE lunch_order_transactions
CHANGE COLUMN scan_time_utc scan_date DATETIME(3) NOT NULL;
-- 4) Rename received_at_utc → received_date
ALTER TABLE lunch_order_transactions
CHANGE COLUMN received_at_utc received_date DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3));
-- 5) Add new unique key so INSERT IGNORE still deduplicates (same site/device/card/time = one row)
ALTER TABLE lunch_order_transactions
ADD UNIQUE KEY ux_lunch_order_tx_site_device_card_scan_date (
site_id,
device_id,
card_id,
scan_date
);