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();
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59).</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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 (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"
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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": "",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue