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
parent
4bf3cced5f
commit
f19316b04f
|
|
@ -62,6 +62,7 @@ public class AppDbContext : DbContext
|
|||
MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
|
||||
UpgradeLunchOrderTransactionsSchemaIfNeeded();
|
||||
EnsureAdminLoginTableExists();
|
||||
RemoveLegacyMealRelatedSchemaIfNeeded();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -193,4 +194,92 @@ public class AppDbContext : DbContext
|
|||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
/// <summary>The ordered item name, e.g. "Chicken Biryani" (modal only for now).</summary>
|
||||
public string OrderItem { get; set; } = string.Empty;
|
||||
/// <summary>The ordered item name (Sehri/Iftari).</summary>
|
||||
public string OrderItem { get; set; } = "Sehri/Iftari";
|
||||
/// <summary>Display label: "Today", "Yesterday", or short date.</summary>
|
||||
public string RelativeDateLabel { get; set; } = string.Empty;
|
||||
/// <summary>Time only, e.g. "09:54 AM".</summary>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ApplicationRevision>10</ApplicationRevision>
|
||||
<ApplicationRevision>11</ApplicationRevision>
|
||||
<ApplicationVersion>1.0.0.*</ApplicationVersion>
|
||||
<BootstrapperEnabled>True</BootstrapperEnabled>
|
||||
<Configuration>Release</Configuration>
|
||||
|
|
@ -33,6 +33,6 @@
|
|||
<UpdateRequired>False</UpdateRequired>
|
||||
<UpdateUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</UpdateUrl>
|
||||
<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>
|
||||
</Project>
|
||||
|
|
@ -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<AppConfig>(json);
|
||||
return config ?? new AppConfig();
|
||||
config ??= new AppConfig();
|
||||
MigrateScanIntervalFromSecondsIfNeeded(config);
|
||||
return config;
|
||||
}
|
||||
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()
|
||||
{
|
||||
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;
|
||||
/// <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 SiteId { get; set; } = "SITE : 1";
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,17 @@ public interface IConfigService
|
|||
void SetSyncApiEndpoint(string endpoint);
|
||||
bool GetScannerConnected();
|
||||
void SetScannerConnected(bool connected);
|
||||
int GetScanTimeoutSeconds();
|
||||
void SetScanTimeoutSeconds(int seconds);
|
||||
/// <summary>Scan interval: minimum time between scans. Days (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59).</summary>
|
||||
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();
|
||||
void SetAdminCardId(string cardId);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/// <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]
|
||||
private string _employeeOrderItem = "Chicken Biryani";
|
||||
private string _employeeOrderItem = "Sehri/Iftari";
|
||||
|
||||
/// <summary>Optional profile image path; null = show placeholder.</summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Loads SyncApiEndpoint from config service.</summary>
|
||||
/// <summary>Loads config from service.</summary>
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -437,14 +437,16 @@
|
|||
<RowDefinition Height="*" MinHeight="120"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Title + Close X -->
|
||||
<!-- Title + Download records + Close X -->
|
||||
<Grid Grid.Row="0" Margin="24,20,16,16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<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>
|
||||
<!-- Scrollable list -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Margin="24,4,24,12" Padding="0,0,4,0">
|
||||
|
|
|
|||
|
|
@ -228,10 +228,39 @@
|
|||
|
||||
<!-- Form fields -->
|
||||
<StackPanel Grid.Row="1" Margin="24,0,24,0">
|
||||
<TextBlock Text="Scan Timeout (seconds)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding ScanTimeoutSeconds, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,8" />
|
||||
<TextBlock Text="The minimum time between scans to prevent duplicates."
|
||||
<TextBlock Text="Scan interval (minimum time between scans)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
|
||||
<Grid Margin="0,8,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<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 (0–365)" 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 (0–23)" 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 (0–59)" 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 (0–59)" 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"
|
||||
Foreground="{StaticResource MutedText}"
|
||||
Margin="0,0,0,20" />
|
||||
|
|
@ -284,7 +313,6 @@
|
|||
Foreground="{StaticResource MutedText}"
|
||||
Margin="0,0,0,20" />
|
||||
|
||||
|
||||
<!-- Message area -->
|
||||
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
|
||||
<Border.Style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{
|
||||
"SyncApiEndpoint": "https://api.example.com/uind/sync",
|
||||
"ScannerConnected": false,
|
||||
"ScanTimeoutSeconds": 60,
|
||||
"ScanIntervalDays": 0,
|
||||
"ScanIntervalHours": 0,
|
||||
"ScanIntervalMinutes": 1,
|
||||
"AdminCardId": "ADMIN",
|
||||
"SiteId": "02",
|
||||
"DeviceId": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue