From f19316b04fb1e2008421cd823d420047233111a6 Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Wed, 18 Feb 2026 16:50:04 +0500 Subject: [PATCH] Database cleanup and production sync improvements - Remove legacy SQLite tables/columns (Employees, MealRates, TemporaryMealPlanOverrides, etc.) - Replace scan timeout with granular interval (Days/Hours/Minutes/Seconds) - Add CSV export for today's records in Order History modal - Consolidate cooldown alerts with dynamic unit formatting - Change order items to "Sehri/Iftari" - Update MySQL sync: remove device_local_row_id, rename columns (scan_date, received_date) - Add production migration script for schema changes - Remove MySQL connection string from Settings UI (use appsettings.json) --- Data/AppDbContext.cs | 89 +++++++++++++ Models/OrderHistoryItem.cs | 4 +- .../PublishProfiles/ClickOnceProfile.pubxml | 4 +- Services/ConfigService.cs | 87 ++++++++++++- Services/IConfigService.cs | 13 +- Services/RfidService.cs | 5 +- Services/SyncService.cs | 10 +- ViewModels/MainDashoardViewModel.cs | 8 +- ViewModels/ScannerDashboardViewModel.cs | 123 ++++++++++++++---- ViewModels/SettingsViewModel.cs | 63 +++++++-- Views/ScannerDashboardView.xaml | 6 +- Views/SettingsView.xaml | 38 +++++- appsettings.example.json | 4 +- 13 files changed, 384 insertions(+), 70 deletions(-) diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0b2fe5b..587a115 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -62,6 +62,7 @@ public class AppDbContext : DbContext MigrateScanRecordsToLunchOrderTransactionsIfNeeded(); UpgradeLunchOrderTransactionsSchemaIfNeeded(); EnsureAdminLoginTableExists(); + RemoveLegacyMealRelatedSchemaIfNeeded(); } /// @@ -193,4 +194,92 @@ public class AppDbContext : DbContext // Ignore; table may already exist or DB may be read-only. } } + + /// + /// Removes legacy meal-related tables and columns that should never be present in production. + /// This is a best-effort cleanup that runs on every startup for existing SQLite databases. + /// + private void RemoveLegacyMealRelatedSchemaIfNeeded() + { + try + { + var conn = Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + // 1) Drop whole legacy tables if they exist. + var legacyTables = new[] + { + "Employees", + "MealRates", + // Support both spellings of the temporary meal plan overrides table. + "TemporaryMealPlanOverirdes", + "TemporaryMealPlanOverrides" + }; + + foreach (var table in legacyTables) + { + using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP TABLE IF EXISTS \"{table}\""; + dropCmd.ExecuteNonQuery(); + } + + // 2) Strip specific columns from any user tables that might contain them. + // We don't assume which table they belong to; instead we scan all non-system tables. + var legacyColumns = new[] + { + "MealSession", + "MealPlanType", + "AppliedRate", + "TotalAmount" + }; + + var tableNames = new List(); + using (var tablesCmd = conn.CreateCommand()) + { + tablesCmd.CommandText = + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"; + using var reader = tablesCmd.ExecuteReader(); + while (reader.Read()) + tableNames.Add(reader.GetString(0)); + } + + foreach (var tableName in tableNames) + { + var existingColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + using (var columnsCmd = conn.CreateCommand()) + { + // Use pragma_table_info to discover columns for the current table. + var safeTableName = tableName.Replace("'", "''"); + columnsCmd.CommandText = $"SELECT name FROM pragma_table_info('{safeTableName}')"; + using var reader = columnsCmd.ExecuteReader(); + while (reader.Read()) + existingColumns.Add(reader.GetString(0)); + } + + foreach (var legacyColumn in legacyColumns) + { + if (!existingColumns.Contains(legacyColumn)) + continue; + + try + { + using var dropColumnCmd = conn.CreateCommand(); + dropColumnCmd.CommandText = + $"ALTER TABLE \"{tableName}\" DROP COLUMN \"{legacyColumn}\""; + dropColumnCmd.ExecuteNonQuery(); + } + catch + { + // Older SQLite versions may not support DROP COLUMN; ignore in that case. + // If needed in the future, a more invasive recreate-table migration can be added. + } + } + } + } + catch + { + // Best-effort only: if anything fails, do not block app startup. + } + } } diff --git a/Models/OrderHistoryItem.cs b/Models/OrderHistoryItem.cs index 194aa1b..12e5e5e 100644 --- a/Models/OrderHistoryItem.cs +++ b/Models/OrderHistoryItem.cs @@ -10,8 +10,8 @@ public class OrderHistoryItem public string Department { get; set; } = string.Empty; public string ScanId { get; set; } = string.Empty; public DateTime OrderTimeUtc { get; set; } - /// The ordered item name, e.g. "Chicken Biryani" (modal only for now). - public string OrderItem { get; set; } = string.Empty; + /// The ordered item name (Sehri/Iftari). + public string OrderItem { get; set; } = "Sehri/Iftari"; /// Display label: "Today", "Yesterday", or short date. public string RelativeDateLabel { get; set; } = string.Empty; /// Time only, e.g. "09:54 AM". diff --git a/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Properties/PublishProfiles/ClickOnceProfile.pubxml index 8430182..83446e0 100644 --- a/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ b/Properties/PublishProfiles/ClickOnceProfile.pubxml @@ -2,7 +2,7 @@ - 10 + 11 1.0.0.* True Release @@ -33,6 +33,6 @@ False \\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\ Publish.html - True|2026-02-12T06:22:02.6537306Z||;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||; + 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||; \ No newline at end of file diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 1c35bb2..a7903cb 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -38,14 +38,50 @@ public class ConfigService : IConfigService SaveConfig(); } - public int GetScanTimeoutSeconds() => _config.ScanTimeoutSeconds; + private const int ScanIntervalDaysMax = 365; + private const int ScanIntervalHoursMax = 23; + private const int ScanIntervalMinutesMax = 59; + private const int ScanIntervalSecondsMax = 59; + private static readonly TimeSpan ScanIntervalFallback = TimeSpan.FromMinutes(1); - public void SetScanTimeoutSeconds(int seconds) + public int GetScanIntervalDays() => Math.Clamp(_config.ScanIntervalDays, 0, ScanIntervalDaysMax); + public void SetScanIntervalDays(int value) { - _config.ScanTimeoutSeconds = seconds; + _config.ScanIntervalDays = Math.Clamp(value, 0, ScanIntervalDaysMax); SaveConfig(); } + public int GetScanIntervalHours() => Math.Clamp(_config.ScanIntervalHours, 0, ScanIntervalHoursMax); + public void SetScanIntervalHours(int value) + { + _config.ScanIntervalHours = Math.Clamp(value, 0, ScanIntervalHoursMax); + SaveConfig(); + } + + public int GetScanIntervalMinutes() => Math.Clamp(_config.ScanIntervalMinutes, 0, ScanIntervalMinutesMax); + public void SetScanIntervalMinutes(int value) + { + _config.ScanIntervalMinutes = Math.Clamp(value, 0, ScanIntervalMinutesMax); + SaveConfig(); + } + + public int GetScanIntervalSeconds() => Math.Clamp(_config.ScanIntervalSeconds, 0, ScanIntervalSecondsMax); + public void SetScanIntervalSeconds(int value) + { + _config.ScanIntervalSeconds = Math.Clamp(value, 0, ScanIntervalSecondsMax); + SaveConfig(); + } + + public TimeSpan GetScanInterval() + { + var days = Math.Clamp(_config.ScanIntervalDays, 0, ScanIntervalDaysMax); + var hours = Math.Clamp(_config.ScanIntervalHours, 0, ScanIntervalHoursMax); + var minutes = Math.Clamp(_config.ScanIntervalMinutes, 0, ScanIntervalMinutesMax); + var seconds = Math.Clamp(_config.ScanIntervalSeconds, 0, ScanIntervalSecondsMax); + var ts = TimeSpan.FromDays(days) + TimeSpan.FromHours(hours) + TimeSpan.FromMinutes(minutes) + TimeSpan.FromSeconds(seconds); + return ts > TimeSpan.Zero ? ts : ScanIntervalFallback; + } + public string GetAdminCardId() => _config.AdminCardId ?? string.Empty; public void SetAdminCardId(string cardId) @@ -201,7 +237,9 @@ public class ConfigService : IConfigService var json = File.ReadAllText(_configPath); var config = JsonSerializer.Deserialize(json); - return config ?? new AppConfig(); + config ??= new AppConfig(); + MigrateScanIntervalFromSecondsIfNeeded(config); + return config; } catch { @@ -209,6 +247,40 @@ public class ConfigService : IConfigService } } + /// + /// One-time migration: if old ScanTimeoutSeconds is set and new D/H/M are all zero, convert and persist. + /// + private void MigrateScanIntervalFromSecondsIfNeeded(AppConfig config) + { + var legacySeconds = config.ScanTimeoutSeconds; + if (legacySeconds <= 0) + return; + var d = config.ScanIntervalDays; + var h = config.ScanIntervalHours; + var m = config.ScanIntervalMinutes; + var s = config.ScanIntervalSeconds; + if (d != 0 || h != 0 || m != 0 || s != 0) + return; // Already using new format + + var totalSeconds = Math.Max(0, legacySeconds); + var days = Math.Min(totalSeconds / 86400, ScanIntervalDaysMax); + var remainder = totalSeconds % 86400; + var hours = Math.Min(remainder / 3600, ScanIntervalHoursMax); + remainder %= 3600; + var minutes = Math.Min(remainder / 60, ScanIntervalMinutesMax); + var seconds = Math.Min(remainder % 60, ScanIntervalSecondsMax); + if (days == 0 && hours == 0 && minutes == 0 && seconds == 0) + seconds = 1; + + config.ScanIntervalDays = days; + config.ScanIntervalHours = hours; + config.ScanIntervalMinutes = minutes; + config.ScanIntervalSeconds = seconds; + config.ScanTimeoutSeconds = 0; + _config = config; + SaveConfig(config); + } + private void SaveConfig() { SaveConfig(_config); @@ -234,7 +306,12 @@ public class ConfigService : IConfigService { public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync"; public bool ScannerConnected { get; set; } = false; - public int ScanTimeoutSeconds { get; set; } = 60; + /// Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization. + public int ScanTimeoutSeconds { get; set; } = 0; + public int ScanIntervalDays { get; set; } = 0; + public int ScanIntervalHours { get; set; } = 0; + public int ScanIntervalMinutes { get; set; } = 1; + public int ScanIntervalSeconds { get; set; } = 0; public string AdminCardId { get; set; } = "ADMIN"; public string SiteId { get; set; } = "SITE : 1"; public string DeviceId { get; set; } = string.Empty; diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs index b1f98ee..b0be860 100644 --- a/Services/IConfigService.cs +++ b/Services/IConfigService.cs @@ -9,8 +9,17 @@ public interface IConfigService void SetSyncApiEndpoint(string endpoint); bool GetScannerConnected(); void SetScannerConnected(bool connected); - int GetScanTimeoutSeconds(); - void SetScanTimeoutSeconds(int seconds); + /// Scan interval: minimum time between scans. Days (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59). + int GetScanIntervalDays(); + void SetScanIntervalDays(int value); + int GetScanIntervalHours(); + void SetScanIntervalHours(int value); + int GetScanIntervalMinutes(); + void SetScanIntervalMinutes(int value); + int GetScanIntervalSeconds(); + void SetScanIntervalSeconds(int value); + /// Computed interval from D+H+M+S with clamping. If all zero, returns default (1 minute). + TimeSpan GetScanInterval(); string GetAdminCardId(); void SetAdminCardId(string cardId); diff --git a/Services/RfidService.cs b/Services/RfidService.cs index 3be1fe3..dc3274f 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -40,9 +40,8 @@ public class RfidService : IRfidService using var db = _dbFactory.CreateDbContext(); - var timeoutSeconds = _configService.GetScanTimeoutSeconds(); - if (timeoutSeconds <= 0) - timeoutSeconds = 60; + var interval = _configService.GetScanInterval(); + var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds); var windowStart = nowUtc.AddSeconds(-timeoutSeconds); diff --git a/Services/SyncService.cs b/Services/SyncService.cs index d515b6e..07dff84 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -55,21 +55,19 @@ public class SyncService : ISyncService try { - // Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId) - // This ensures no duplicates even if sync runs multiple times - // Column names match production: hrms.lunch_order_transactions (snake_case) + // INSERT IGNORE: duplicates avoided by UNIQUE(site_id, device_id, card_id, scan_date) in production. + // Column names match production: hrms.lunch_order_transactions (snake_case). No device_local_row_id. var insertSql = @" INSERT IGNORE INTO lunch_order_transactions - (device_local_row_id, scan_time_utc, site_id, device_id, card_id, ip_address, received_at_utc) + (scan_date, site_id, device_id, card_id, ip_address, received_date) VALUES - (@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))"; + (@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))"; await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction); foreach (var record in toSync) { cmd.Parameters.Clear(); - cmd.Parameters.AddWithValue("@DeviceLocalRowId", record.Id); cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime); cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty); cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty); diff --git a/ViewModels/MainDashoardViewModel.cs b/ViewModels/MainDashoardViewModel.cs index 282cdbb..cff4711 100644 --- a/ViewModels/MainDashoardViewModel.cs +++ b/ViewModels/MainDashoardViewModel.cs @@ -236,13 +236,7 @@ public partial class MainDashboardViewModel : ObservableObject _timerCancellation?.Dispose(); } - private TimeSpan GetDashboardTimeout() - { - var seconds = _configService.GetScanTimeoutSeconds(); - if (seconds <= 0) - seconds = 60; - return TimeSpan.FromSeconds(seconds); - } + private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval(); private void UpdateCurrentTime() { diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index cc070fd..6626d45 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -1,5 +1,9 @@ using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Text; using DebounceTimer = System.Timers.Timer; +using Microsoft.Win32; using System.Windows; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -113,9 +117,9 @@ public partial class ScannerDashboardViewModel : ObservableObject [ObservableProperty] private string _employeeId = "15399"; - /// Item selected by the employee for the order (e.g. Chicken Biryani). + /// Item selected by the employee for the order (Sehri/Iftari). [ObservableProperty] - private string _employeeOrderItem = "Chicken Biryani"; + private string _employeeOrderItem = "Sehri/Iftari"; /// Optional profile image path; null = show placeholder. [ObservableProperty] @@ -483,21 +487,6 @@ public partial class ScannerDashboardViewModel : ObservableObject var employeeName = string.IsNullOrEmpty(r.CardId) ? "—" : $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}"; - // Demo-only order item: rotate through a small list based on card hash - var sampleOrders = new[] - { - "Chicken Biryani", - "Veg Thali", - "Grilled Sandwich", - "Pasta Alfredo", - "Chicken Shawarma", - "Paneer Wrap" - }; - var cardKey = r.CardId ?? string.Empty; - var orderIndex = sampleOrders.Length == 0 - ? 0 - : Math.Abs(cardKey.GetHashCode()) % sampleOrders.Length; - var orderItem = sampleOrders[orderIndex]; TodayOrderHistory.Add(new OrderHistoryItem { @@ -506,7 +495,7 @@ public partial class ScannerDashboardViewModel : ObservableObject Department = "—", ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId OrderTimeUtc = r.ScanTime, - OrderItem = orderItem, + OrderItem = "Sehri/Iftari", TimeDisplay = local.ToString("hh:mm tt"), RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) }); @@ -530,6 +519,79 @@ public partial class ScannerDashboardViewModel : ObservableObject IsOrderHistoryModalOpen = false; } + /// + /// Exports today's scan records to a CSV file (opens in Excel). User chooses path via Save File dialog. + /// + [RelayCommand] + private void DownloadTodayRecords() + { + var records = _rfidService.GetScansForToday(); + if (records.Count == 0) + { + Message = "No records for today to download."; + IsSuccess = false; + return; + } + + var dateStr = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var defaultFileName = $"CanteenRecords_{dateStr}.csv"; + + var dialog = new SaveFileDialog + { + Filter = "CSV (Excel)|*.csv|All files|*.*", + DefaultExt = ".csv", + FileName = defaultFileName + }; + + if (dialog.ShowDialog() != true) + return; + + try + { + var csv = BuildCsvFromScanRecords(records); + var utf8WithBom = new UTF8Encoding(true); + File.WriteAllText(dialog.FileName, csv, utf8WithBom); + Message = $"Downloaded {records.Count} record(s) to {Path.GetFileName(dialog.FileName)}"; + IsSuccess = true; + } + catch (Exception ex) + { + Message = "Download failed: " + ex.Message; + IsSuccess = false; + } + } + + private static string BuildCsvFromScanRecords(IReadOnlyList records) + { + var sb = new StringBuilder(); + sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress"); + foreach (var r in records) + { + sb.Append(r.Id); + sb.Append(','); + sb.Append(EscapeCsv(r.CardId)); + sb.Append(','); + sb.Append(EscapeCsv(r.ScanTime.ToString("O", CultureInfo.InvariantCulture))); + sb.Append(','); + sb.Append(r.IsSynced ? "Yes" : "No"); + sb.Append(','); + sb.Append(EscapeCsv(r.SiteId)); + sb.Append(','); + sb.Append(EscapeCsv(r.DeviceId)); + sb.Append(','); + sb.Append(EscapeCsv(r.IpAddress)); + sb.AppendLine(); + } + return sb.ToString(); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) return value; + if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return value; + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + private void StartCooldownCountdown(int seconds) { if (seconds <= 0) @@ -541,7 +603,8 @@ public partial class ScannerDashboardViewModel : ObservableObject _cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty; _cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds); _lastDisplayedCooldownSeconds = -1; - CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({seconds} seconds remaining)"; + Message = string.Empty; // single alert only: cooldown box + CooldownAlertMessage = FormatCooldownMessage(seconds); UpdateCooldownMessage(); _cooldownTimer.Start(); } @@ -557,6 +620,23 @@ public partial class ScannerDashboardViewModel : ObservableObject CooldownAlertMessage = string.Empty; } + /// Formats remaining seconds as one concise phrase in the best unit (e.g. "2 days", "1 minute", "30 seconds"). + private static string FormatRemainingInBestUnit(int totalSeconds) + { + if (totalSeconds <= 0) return "0 seconds"; + if (totalSeconds >= 86400) { var d = totalSeconds / 86400; return d == 1 ? "1 day" : $"{d} days"; } + if (totalSeconds >= 3600) { var h = totalSeconds / 3600; return h == 1 ? "1 hour" : $"{h} hours"; } + if (totalSeconds >= 60) { var m = totalSeconds / 60; return m == 1 ? "1 minute" : $"{m} minutes"; } + return totalSeconds == 1 ? "1 second" : $"{totalSeconds} seconds"; + } + + /// Single concise cooldown line for the one visible alert. + private static string FormatCooldownMessage(int remainingSeconds) + { + var phrase = FormatRemainingInBestUnit(remainingSeconds); + return $"Rescan in {phrase}."; + } + private void UpdateCooldownMessage() { if (_cooldownEndsUtc is null) @@ -579,9 +659,8 @@ public partial class ScannerDashboardViewModel : ObservableObject return; _lastDisplayedCooldownSeconds = remaining; - var unit = remaining == 1 ? "second" : "seconds"; - Message = $"Please wait {remaining} {unit}…"; - CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({remaining} {unit} remaining)"; + Message = string.Empty; // single alert only + CooldownAlertMessage = FormatCooldownMessage(remaining); IsSuccess = false; } diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index df728c7..59a790a 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -18,7 +18,16 @@ public partial class SettingsViewModel : ObservableObject private string _syncApiEndpoint = string.Empty; [ObservableProperty] - private string _scanTimeoutSeconds = "30"; + private string _scanIntervalDays = "0"; + + [ObservableProperty] + private string _scanIntervalHours = "0"; + + [ObservableProperty] + private string _scanIntervalMinutes = "1"; + + [ObservableProperty] + private string _scanIntervalSeconds = "0"; [ObservableProperty] private string _adminCardId = "ADMIN"; @@ -58,11 +67,14 @@ public partial class SettingsViewModel : ObservableObject LoadFromConfig(); } - /// Loads SyncApiEndpoint from config service. + /// Loads config from service. public void LoadFromConfig() { SyncApiEndpoint = _configService.GetSyncApiEndpoint(); - ScanTimeoutSeconds = _configService.GetScanTimeoutSeconds().ToString(); + ScanIntervalDays = _configService.GetScanIntervalDays().ToString(); + ScanIntervalHours = _configService.GetScanIntervalHours().ToString(); + ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString(); + ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString(); // Always prefer the latest admin login from the database. var last = _adminAudit.GetLastLogin(); @@ -76,16 +88,47 @@ public partial class SettingsViewModel : ObservableObject private void Save() { IsSaving = true; - if (!int.TryParse(ScanTimeoutSeconds, out var seconds) || seconds <= 0) + if (!int.TryParse(ScanIntervalDays, out var days) || days < 0 || days > 365) { - SaveMessage = "Scan timeout must be a positive number of seconds."; + SaveMessage = "Scan interval Days must be 0–365."; + IsError = true; + IsSaving = false; + return; + } + if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23) + { + SaveMessage = "Scan interval Hours must be 0–23."; + IsError = true; + IsSaving = false; + return; + } + if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59) + { + SaveMessage = "Scan interval Minutes must be 0–59."; + IsError = true; + IsSaving = false; + return; + } + if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59) + { + SaveMessage = "Scan interval Seconds must be 0–59."; + IsError = true; + IsSaving = false; + return; + } + if (days == 0 && hours == 0 && minutes == 0 && seconds == 0) + { + SaveMessage = "Scan interval cannot be zero. Use at least 1 second."; IsError = true; IsSaving = false; return; } _configService.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty); - _configService.SetScanTimeoutSeconds(seconds); + _configService.SetScanIntervalDays(days); + _configService.SetScanIntervalHours(hours); + _configService.SetScanIntervalMinutes(minutes); + _configService.SetScanIntervalSeconds(seconds); _configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty); SaveMessage = "Settings saved."; IsError = false; @@ -133,11 +176,5 @@ public partial class SettingsViewModel : ObservableObject } } - private TimeSpan GetDashboardTimeout() - { - var seconds = _configService.GetScanTimeoutSeconds(); - if (seconds <= 0) - seconds = 60; - return TimeSpan.FromSeconds(seconds); - } + private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval(); } diff --git a/Views/ScannerDashboardView.xaml b/Views/ScannerDashboardView.xaml index 56cd05c..ac73bdf 100644 --- a/Views/ScannerDashboardView.xaml +++ b/Views/ScannerDashboardView.xaml @@ -437,14 +437,16 @@ - + + -