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)
pull/1/head
SYED MUSTUFA AHMED NAQVI 2026-02-18 16:50:04 +05:00
parent 4bf3cced5f
commit f19316b04f
13 changed files with 384 additions and 70 deletions

View File

@ -62,6 +62,7 @@ public class AppDbContext : DbContext
MigrateScanRecordsToLunchOrderTransactionsIfNeeded(); MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
UpgradeLunchOrderTransactionsSchemaIfNeeded(); UpgradeLunchOrderTransactionsSchemaIfNeeded();
EnsureAdminLoginTableExists(); EnsureAdminLoginTableExists();
RemoveLegacyMealRelatedSchemaIfNeeded();
} }
/// <summary> /// <summary>
@ -193,4 +194,92 @@ public class AppDbContext : DbContext
// Ignore; table may already exist or DB may be read-only. // Ignore; table may already exist or DB may be read-only.
} }
} }
/// <summary>
/// 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.
/// </summary>
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<string>();
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<string>(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.
}
}
} }

View File

@ -10,8 +10,8 @@ public class OrderHistoryItem
public string Department { get; set; } = string.Empty; public string Department { get; set; } = string.Empty;
public string ScanId { get; set; } = string.Empty; public string ScanId { get; set; } = string.Empty;
public DateTime OrderTimeUtc { get; set; } public DateTime OrderTimeUtc { get; set; }
/// <summary>The ordered item name, e.g. "Chicken Biryani" (modal only for now).</summary> /// <summary>The ordered item name (Sehri/Iftari).</summary>
public string OrderItem { get; set; } = string.Empty; public string OrderItem { get; set; } = "Sehri/Iftari";
/// <summary>Display label: "Today", "Yesterday", or short date.</summary> /// <summary>Display label: "Today", "Yesterday", or short date.</summary>
public string RelativeDateLabel { get; set; } = string.Empty; public string RelativeDateLabel { get; set; } = string.Empty;
/// <summary>Time only, e.g. "09:54 AM".</summary> /// <summary>Time only, e.g. "09:54 AM".</summary>

View File

@ -2,7 +2,7 @@
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. --> <!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ApplicationRevision>10</ApplicationRevision> <ApplicationRevision>11</ApplicationRevision>
<ApplicationVersion>1.0.0.*</ApplicationVersion> <ApplicationVersion>1.0.0.*</ApplicationVersion>
<BootstrapperEnabled>True</BootstrapperEnabled> <BootstrapperEnabled>True</BootstrapperEnabled>
<Configuration>Release</Configuration> <Configuration>Release</Configuration>
@ -33,6 +33,6 @@
<UpdateRequired>False</UpdateRequired> <UpdateRequired>False</UpdateRequired>
<UpdateUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</UpdateUrl> <UpdateUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</UpdateUrl>
<WebPageFileName>Publish.html</WebPageFileName> <WebPageFileName>Publish.html</WebPageFileName>
<History>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||;</History> <History>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||;</History>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -38,14 +38,50 @@ public class ConfigService : IConfigService
SaveConfig(); 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(); 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 string GetAdminCardId() => _config.AdminCardId ?? string.Empty;
public void SetAdminCardId(string cardId) public void SetAdminCardId(string cardId)
@ -201,7 +237,9 @@ public class ConfigService : IConfigService
var json = File.ReadAllText(_configPath); var json = File.ReadAllText(_configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json); var config = JsonSerializer.Deserialize<AppConfig>(json);
return config ?? new AppConfig(); config ??= new AppConfig();
MigrateScanIntervalFromSecondsIfNeeded(config);
return config;
} }
catch catch
{ {
@ -209,6 +247,40 @@ public class ConfigService : IConfigService
} }
} }
/// <summary>
/// One-time migration: if old ScanTimeoutSeconds is set and new D/H/M are all zero, convert and persist.
/// </summary>
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() private void SaveConfig()
{ {
SaveConfig(_config); SaveConfig(_config);
@ -234,7 +306,12 @@ public class ConfigService : IConfigService
{ {
public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync"; public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync";
public bool ScannerConnected { get; set; } = false; public bool ScannerConnected { get; set; } = false;
public int ScanTimeoutSeconds { get; set; } = 60; /// <summary>Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization.</summary>
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 AdminCardId { get; set; } = "ADMIN";
public string SiteId { get; set; } = "SITE : 1"; public string SiteId { get; set; } = "SITE : 1";
public string DeviceId { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty;

View File

@ -9,8 +9,17 @@ public interface IConfigService
void SetSyncApiEndpoint(string endpoint); void SetSyncApiEndpoint(string endpoint);
bool GetScannerConnected(); bool GetScannerConnected();
void SetScannerConnected(bool connected); void SetScannerConnected(bool connected);
int GetScanTimeoutSeconds(); /// <summary>Scan interval: minimum time between scans. Days (0365), Hours (023), Minutes (059), Seconds (059).</summary>
void SetScanTimeoutSeconds(int seconds); int GetScanIntervalDays();
void SetScanIntervalDays(int value);
int GetScanIntervalHours();
void SetScanIntervalHours(int value);
int GetScanIntervalMinutes();
void SetScanIntervalMinutes(int value);
int GetScanIntervalSeconds();
void SetScanIntervalSeconds(int value);
/// <summary>Computed interval from D+H+M+S with clamping. If all zero, returns default (1 minute).</summary>
TimeSpan GetScanInterval();
string GetAdminCardId(); string GetAdminCardId();
void SetAdminCardId(string cardId); void SetAdminCardId(string cardId);

View File

@ -40,9 +40,8 @@ public class RfidService : IRfidService
using var db = _dbFactory.CreateDbContext(); using var db = _dbFactory.CreateDbContext();
var timeoutSeconds = _configService.GetScanTimeoutSeconds(); var interval = _configService.GetScanInterval();
if (timeoutSeconds <= 0) var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
timeoutSeconds = 60;
var windowStart = nowUtc.AddSeconds(-timeoutSeconds); var windowStart = nowUtc.AddSeconds(-timeoutSeconds);

View File

@ -55,21 +55,19 @@ public class SyncService : ISyncService
try try
{ {
// Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId) // INSERT IGNORE: duplicates avoided by UNIQUE(site_id, device_id, card_id, scan_date) in production.
// This ensures no duplicates even if sync runs multiple times // Column names match production: hrms.lunch_order_transactions (snake_case). No device_local_row_id.
// Column names match production: hrms.lunch_order_transactions (snake_case)
var insertSql = @" var insertSql = @"
INSERT IGNORE INTO lunch_order_transactions 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 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); await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction);
foreach (var record in toSync) foreach (var record in toSync)
{ {
cmd.Parameters.Clear(); cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("@DeviceLocalRowId", record.Id);
cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime); cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime);
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty); cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty); cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);

View File

@ -236,13 +236,7 @@ public partial class MainDashboardViewModel : ObservableObject
_timerCancellation?.Dispose(); _timerCancellation?.Dispose();
} }
private TimeSpan GetDashboardTimeout() private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval();
{
var seconds = _configService.GetScanTimeoutSeconds();
if (seconds <= 0)
seconds = 60;
return TimeSpan.FromSeconds(seconds);
}
private void UpdateCurrentTime() private void UpdateCurrentTime()
{ {

View File

@ -1,5 +1,9 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Text;
using DebounceTimer = System.Timers.Timer; using DebounceTimer = System.Timers.Timer;
using Microsoft.Win32;
using System.Windows; using System.Windows;
using System.Windows.Threading; using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -113,9 +117,9 @@ public partial class ScannerDashboardViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _employeeId = "15399"; private string _employeeId = "15399";
/// <summary>Item selected by the employee for the order (e.g. Chicken Biryani).</summary> /// <summary>Item selected by the employee for the order (Sehri/Iftari).</summary>
[ObservableProperty] [ObservableProperty]
private string _employeeOrderItem = "Chicken Biryani"; private string _employeeOrderItem = "Sehri/Iftari";
/// <summary>Optional profile image path; null = show placeholder.</summary> /// <summary>Optional profile image path; null = show placeholder.</summary>
[ObservableProperty] [ObservableProperty]
@ -483,21 +487,6 @@ public partial class ScannerDashboardViewModel : ObservableObject
var employeeName = string.IsNullOrEmpty(r.CardId) var employeeName = string.IsNullOrEmpty(r.CardId)
? "—" ? "—"
: $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : 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 TodayOrderHistory.Add(new OrderHistoryItem
{ {
@ -506,7 +495,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
Department = "—", Department = "—",
ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId
OrderTimeUtc = r.ScanTime, OrderTimeUtc = r.ScanTime,
OrderItem = orderItem, OrderItem = "Sehri/Iftari",
TimeDisplay = local.ToString("hh:mm tt"), TimeDisplay = local.ToString("hh:mm tt"),
RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) RelativeDateLabel = GetRelativeDateLabel(r.ScanTime)
}); });
@ -530,6 +519,79 @@ public partial class ScannerDashboardViewModel : ObservableObject
IsOrderHistoryModalOpen = false; IsOrderHistoryModalOpen = false;
} }
/// <summary>
/// Exports today's scan records to a CSV file (opens in Excel). User chooses path via Save File dialog.
/// </summary>
[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<ScanRecord> 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) private void StartCooldownCountdown(int seconds)
{ {
if (seconds <= 0) if (seconds <= 0)
@ -541,7 +603,8 @@ public partial class ScannerDashboardViewModel : ObservableObject
_cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty; _cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty;
_cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds); _cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds);
_lastDisplayedCooldownSeconds = -1; _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(); UpdateCooldownMessage();
_cooldownTimer.Start(); _cooldownTimer.Start();
} }
@ -557,6 +620,23 @@ public partial class ScannerDashboardViewModel : ObservableObject
CooldownAlertMessage = string.Empty; CooldownAlertMessage = string.Empty;
} }
/// <summary>Formats remaining seconds as one concise phrase in the best unit (e.g. "2 days", "1 minute", "30 seconds").</summary>
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";
}
/// <summary>Single concise cooldown line for the one visible alert.</summary>
private static string FormatCooldownMessage(int remainingSeconds)
{
var phrase = FormatRemainingInBestUnit(remainingSeconds);
return $"Rescan in {phrase}.";
}
private void UpdateCooldownMessage() private void UpdateCooldownMessage()
{ {
if (_cooldownEndsUtc is null) if (_cooldownEndsUtc is null)
@ -579,9 +659,8 @@ public partial class ScannerDashboardViewModel : ObservableObject
return; return;
_lastDisplayedCooldownSeconds = remaining; _lastDisplayedCooldownSeconds = remaining;
var unit = remaining == 1 ? "second" : "seconds"; Message = string.Empty; // single alert only
Message = $"Please wait {remaining} {unit}…"; CooldownAlertMessage = FormatCooldownMessage(remaining);
CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({remaining} {unit} remaining)";
IsSuccess = false; IsSuccess = false;
} }

View File

@ -18,7 +18,16 @@ public partial class SettingsViewModel : ObservableObject
private string _syncApiEndpoint = string.Empty; private string _syncApiEndpoint = string.Empty;
[ObservableProperty] [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] [ObservableProperty]
private string _adminCardId = "ADMIN"; private string _adminCardId = "ADMIN";
@ -58,11 +67,14 @@ public partial class SettingsViewModel : ObservableObject
LoadFromConfig(); LoadFromConfig();
} }
/// <summary>Loads SyncApiEndpoint from config service.</summary> /// <summary>Loads config from service.</summary>
public void LoadFromConfig() public void LoadFromConfig()
{ {
SyncApiEndpoint = _configService.GetSyncApiEndpoint(); 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. // Always prefer the latest admin login from the database.
var last = _adminAudit.GetLastLogin(); var last = _adminAudit.GetLastLogin();
@ -76,16 +88,47 @@ public partial class SettingsViewModel : ObservableObject
private void Save() private void Save()
{ {
IsSaving = true; 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 0365.";
IsError = true;
IsSaving = false;
return;
}
if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23)
{
SaveMessage = "Scan interval Hours must be 023.";
IsError = true;
IsSaving = false;
return;
}
if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59)
{
SaveMessage = "Scan interval Minutes must be 059.";
IsError = true;
IsSaving = false;
return;
}
if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59)
{
SaveMessage = "Scan interval Seconds must be 059.";
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; IsError = true;
IsSaving = false; IsSaving = false;
return; return;
} }
_configService.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty); _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); _configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
SaveMessage = "Settings saved."; SaveMessage = "Settings saved.";
IsError = false; IsError = false;
@ -133,11 +176,5 @@ public partial class SettingsViewModel : ObservableObject
} }
} }
private TimeSpan GetDashboardTimeout() private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval();
{
var seconds = _configService.GetScanTimeoutSeconds();
if (seconds <= 0)
seconds = 60;
return TimeSpan.FromSeconds(seconds);
}
} }

View File

@ -437,14 +437,16 @@
<RowDefinition Height="*" MinHeight="120"/> <RowDefinition Height="*" MinHeight="120"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Title + Close X --> <!-- Title + Download records + Close X -->
<Grid Grid.Row="0" Margin="24,20,16,16"> <Grid Grid.Row="0" Margin="24,20,16,16">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Today's Order History" FontSize="20" FontWeight="Bold" Foreground="{StaticResource TitleTextBrush}" VerticalAlignment="Center"/> <TextBlock Text="Today's Order History" FontSize="20" FontWeight="Bold" Foreground="{StaticResource TitleTextBrush}" VerticalAlignment="Center"/>
<Button x:Name="OrderHistoryModalCloseXButton" Grid.Column="1" Click="OrderHistoryModalClose_Click" Background="Transparent" BorderThickness="0" Width="32" Height="32" Padding="0" Cursor="Hand" Content="✕" FontSize="16" Foreground="{StaticResource MutedTextBrush}" ToolTip="Close"/> <Button Grid.Column="1" Content="Download records" Command="{Binding DownloadTodayRecordsCommand}" Style="{StaticResource SecondaryButtonStyle}" Margin="0,0,12,0" MinWidth="140" MinHeight="40" Padding="14,8" FontSize="14" ToolTip="Export today's records to CSV (Excel)" Focusable="False" IsTabStop="False"/>
<Button x:Name="OrderHistoryModalCloseXButton" Grid.Column="2" Click="OrderHistoryModalClose_Click" Background="Transparent" BorderThickness="0" Width="32" Height="32" Padding="0" Cursor="Hand" Content="✕" FontSize="16" Foreground="{StaticResource MutedTextBrush}" ToolTip="Close"/>
</Grid> </Grid>
<!-- Scrollable list --> <!-- Scrollable list -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Margin="24,4,24,12" Padding="0,0,4,0"> <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Margin="24,4,24,12" Padding="0,0,4,0">

View File

@ -228,10 +228,39 @@
<!-- Form fields --> <!-- Form fields -->
<StackPanel Grid.Row="1" Margin="24,0,24,0"> <StackPanel Grid.Row="1" Margin="24,0,24,0">
<TextBlock Text="Scan Timeout (seconds)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" /> <TextBlock Text="Scan interval (minimum time between scans)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding ScanTimeoutSeconds, UpdateSourceTrigger=PropertyChanged}" <Grid Margin="0,8,0,8">
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,8" /> <Grid.ColumnDefinitions>
<TextBlock Text="The minimum time between scans to prevent duplicates." <ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Days (0365)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalDays, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Hours (023)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalHours, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" />
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Minutes (059)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalMinutes, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" />
</StackPanel>
<StackPanel Grid.Column="6">
<TextBlock Text="Seconds (059)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalSeconds, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" />
</StackPanel>
</Grid>
<TextBlock Text="The minimum time between scans to prevent duplicates. At least 1 second required."
FontSize="14" FontSize="14"
Foreground="{StaticResource MutedText}" Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" /> Margin="0,0,0,20" />
@ -284,7 +313,6 @@
Foreground="{StaticResource MutedText}" Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" /> Margin="0,0,0,20" />
<!-- Message area --> <!-- Message area -->
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8"> <Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
<Border.Style> <Border.Style>

View File

@ -1,7 +1,9 @@
{ {
"SyncApiEndpoint": "https://api.example.com/uind/sync", "SyncApiEndpoint": "https://api.example.com/uind/sync",
"ScannerConnected": false, "ScannerConnected": false,
"ScanTimeoutSeconds": 60, "ScanIntervalDays": 0,
"ScanIntervalHours": 0,
"ScanIntervalMinutes": 1,
"AdminCardId": "ADMIN", "AdminCardId": "ADMIN",
"SiteId": "02", "SiteId": "02",
"DeviceId": "", "DeviceId": "",