diff --git a/Services/CanteenBackendApiClient.cs b/Services/CanteenBackendApiClient.cs
new file mode 100644
index 0000000..b762920
--- /dev/null
+++ b/Services/CanteenBackendApiClient.cs
@@ -0,0 +1,336 @@
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using UtopiaCanteen.Shared;
+using UtopiaCanteenSystem.Api;
+using UtopiaCanteenSystem.Models;
+
+namespace UtopiaCanteenSystem.Services;
+
+///
+/// Frontend HTTP client: all scan/history/stats and settings actions go through the central backend API.
+///
+public class CanteenBackendApiClient : ICanteenBackendApiClient
+{
+ private readonly HttpClient _http;
+ private readonly IConfigService _config;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true
+ };
+
+ public CanteenBackendApiClient(HttpClient http, IConfigService config)
+ {
+ _http = http;
+ _config = config;
+ }
+
+ private string BaseUrl => (_config.GetBackendBaseUrl() ?? string.Empty).TrimEnd('/');
+
+ public async Task HealthCheckAsync(CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return false;
+ try
+ {
+ var url = $"{BaseUrl}/api/health";
+ var resp = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return resp != null && string.Equals(resp.Status, "ok", StringComparison.OrdinalIgnoreCase);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.HealthCheckAsync");
+ return false;
+ }
+ }
+
+ public async Task SyncCacheNowAsync(CancellationToken cancellationToken = default) =>
+ await PostJobAsync("/api/cache/sync-now", cancellationToken).ConfigureAwait(false);
+
+ public async Task SyncOrdersNowAsync(CancellationToken cancellationToken = default) =>
+ await PostJobAsync("/api/orders/sync-now", cancellationToken).ConfigureAwait(false);
+
+ public async Task GetCacheStatusAsync(CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return null;
+ try
+ {
+ var url = $"{BaseUrl}/api/cache/status";
+ return await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.GetCacheStatusAsync");
+ return null;
+ }
+ }
+
+ public (bool Success, string Message) ProcessScan(string cardId)
+ {
+ var r = ProcessScanDetailed(cardId);
+ return (r.Success, r.Message);
+ }
+
+ public ScanResult ProcessScanDetailed(string cardId)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return new ScanResult(false, "Backend URL is not configured.", 0);
+
+ try
+ {
+ var url = $"{BaseUrl}/api/rfid/scan";
+ var body = new ScanRequest
+ {
+ CardId = cardId?.Trim() ?? string.Empty,
+ DeviceId = _config.GetDeviceId(),
+ SiteId = _config.GetSiteId(),
+ IpAddress = null
+ };
+
+ using var response = _http.PostAsJsonAsync(url, body, JsonOptions).GetAwaiter().GetResult();
+ if (!response.IsSuccessStatusCode)
+ {
+ var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ return new ScanResult(false,
+ $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0);
+ }
+
+ var dto = response.Content.ReadFromJsonAsync(JsonOptions).GetAwaiter().GetResult();
+ if (dto == null)
+ return new ScanResult(false, "Invalid response from backend.", 0);
+
+ return new ScanResult(
+ dto.Success,
+ dto.Message ?? string.Empty,
+ dto.CooldownSecondsRemaining,
+ dto.Employee == null ? null : ApiDtoMapper.ToModel(dto.Employee),
+ (MealSession)dto.MealSession,
+ dto.EmployeeSiteId,
+ dto.CurrentSiteId);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.ProcessScanDetailed");
+ return new ScanResult(false, "Cannot reach backend: " + ex.Message, 0);
+ }
+ }
+
+ public ScanRecord? GetLastScan() =>
+ GetAsync("/api/rfid/scans/last", d => d == null ? null : ApiDtoMapper.ToEntity(d));
+
+ public IReadOnlyList GetLastScans(int count)
+ {
+ if (count <= 0) return Array.Empty();
+ return GetAsync, IReadOnlyList>(
+ $"/api/rfid/scans/recent?count={count}",
+ list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List());
+ }
+
+ public IReadOnlyList GetScansForToday() =>
+ GetAsync, IReadOnlyList>(
+ "/api/rfid/scans/today",
+ list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List());
+
+ public int GetTodayScanCount() => GetTodayScanCountAsync().GetAwaiter().GetResult();
+
+ public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default) =>
+ await GetCountAsync("/api/rfid/stats/today", cancellationToken).ConfigureAwait(false);
+
+ public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default) =>
+ await GetCountAsync("/api/rfid/stats/total", cancellationToken).ConfigureAwait(false);
+
+ public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(cardId)) return 0;
+ return await GetCountAsync($"/api/rfid/stats/total-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(cardId)) return 0;
+ return await GetCountAsync($"/api/rfid/stats/today-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice)
+ {
+ // Backend owns SQLite; no local update on frontend.
+ }
+
+ private async Task PostJobAsync(string path, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ {
+ return new ManualSyncResponse
+ {
+ Success = false,
+ Message = "Backend URL is not configured."
+ };
+ }
+
+ try
+ {
+ var url = $"{BaseUrl}{path}";
+ using var response = await _http.PostAsync(url, null, cancellationToken).ConfigureAwait(false);
+ var dto = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ if (dto != null)
+ return dto;
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ return new ManualSyncResponse
+ {
+ Success = false,
+ Message = $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}"
+ };
+ }
+
+ return new ManualSyncResponse { Success = false, Message = "Invalid response from backend." };
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, $"CanteenBackendApiClient.PostJobAsync{path}");
+ return new ManualSyncResponse
+ {
+ Success = false,
+ Message = "Cannot reach backend: " + ex.Message
+ };
+ }
+ }
+
+ private async Task GetCountAsync(string path, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl)) return 0;
+ try
+ {
+ var url = $"{BaseUrl}{path}";
+ var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return wrap?.Count ?? 0;
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, $"CanteenBackendApiClient.GetCountAsync{path}");
+ return 0;
+ }
+ }
+
+ private TResult GetAsync(string path, Func map)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return map(default);
+ try
+ {
+ var url = $"{BaseUrl}{path}";
+ var data = _http.GetFromJsonAsync(url, JsonOptions).GetAwaiter().GetResult();
+ return map(data);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, $"CanteenBackendApiClient.GetAsync{path}");
+ return map(default);
+ }
+ }
+
+ public async Task GetHealthAsync(CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return null;
+ try
+ {
+ return await _http.GetFromJsonAsync($"{BaseUrl}/api/health", JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.GetHealthAsync");
+ return null;
+ }
+ }
+
+ public async Task> GetMealSchedulesAsync(CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ return Array.Empty();
+ try
+ {
+ var list = await _http.GetFromJsonAsync>($"{BaseUrl}/api/meal-schedules", JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ return list ?? new List();
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.GetMealSchedulesAsync");
+ return Array.Empty();
+ }
+ }
+
+ public async Task CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default)
+ {
+ EnsureBaseUrl();
+ var url = $"{BaseUrl}/api/meal-schedules";
+ using var response = await _http.PostAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
+ var created = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false);
+ return created?.Id ?? 0;
+ }
+
+ public async Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default)
+ {
+ EnsureBaseUrl();
+ var url = $"{BaseUrl}/api/meal-schedules/{schedule.Id}";
+ using var response = await _http.PutAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default)
+ {
+ EnsureBaseUrl();
+ var url = $"{BaseUrl}/api/meal-schedules/{id}";
+ using var response = await _http.DeleteAsync(url, cancellationToken).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(employeeId))
+ return null;
+ try
+ {
+ var url = $"{BaseUrl}/api/employees/location-site?employeeId={Uri.EscapeDataString(employeeId.Trim())}";
+ var resp = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ return string.IsNullOrWhiteSpace(resp?.LocationSiteId) ? null : resp.LocationSiteId.Trim();
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "CanteenBackendApiClient.GetEmployeeLocationSiteAsync");
+ return null;
+ }
+ }
+
+ private void EnsureBaseUrl()
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ throw new InvalidOperationException("Backend URL is not configured.");
+ }
+
+ private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ if (response.IsSuccessStatusCode)
+ return;
+ var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ throw new HttpRequestException($"Backend error ({(int)response.StatusCode}): {Truncate(err, 300)}");
+ }
+
+ private static string Truncate(string s, int max)
+ {
+ if (string.IsNullOrEmpty(s) || s.Length <= max) return s;
+ return s.Substring(0, max) + "…";
+ }
+}
diff --git a/Services/ClientRfidService.cs b/Services/ClientRfidService.cs
deleted file mode 100644
index cebd462..0000000
--- a/Services/ClientRfidService.cs
+++ /dev/null
@@ -1,218 +0,0 @@
-using System.Net.Http;
-using System.Net.Http.Json;
-using System.Text.Json;
-using UtopiaCanteenSystem.Api;
-using UtopiaCanteenSystem.Models;
-
-namespace UtopiaCanteenSystem.Services;
-
-///
-/// Scanner PC: forwards scans and dashboard queries to the central server HTTP API (no local transaction DB).
-///
-public class ClientRfidService : IRfidService
-{
- private readonly HttpClient _http;
- private readonly IConfigService _config;
-
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true
- };
-
- public ClientRfidService(HttpClient http, IConfigService config)
- {
- _http = http;
- _config = config;
- }
-
- private string BaseUrl => (_config.GetCentralServerBaseUrl() ?? string.Empty).TrimEnd('/');
-
- public (bool Success, string Message) ProcessScan(string cardId)
- {
- var r = ProcessScanDetailed(cardId);
- return (r.Success, r.Message);
- }
-
- public ScanResult ProcessScanDetailed(string cardId)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl))
- return new ScanResult(false, "Central server URL is not configured.", 0);
-
- try
- {
- var url = $"{BaseUrl}/api/rfid/scan";
- var body = new RfidScanRequestDto
- {
- CardId = cardId?.Trim() ?? string.Empty,
- DeviceId = _config.GetDeviceId(),
- SiteId = _config.GetSiteId(),
- IpAddress = null
- };
-
- using var response = _http.PostAsJsonAsync(url, body, JsonOptions).GetAwaiter().GetResult();
- if (!response.IsSuccessStatusCode)
- {
- var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
- return new ScanResult(false,
- $"Central server error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0);
- }
-
- var dto = response.Content.ReadFromJsonAsync(JsonOptions).GetAwaiter().GetResult();
- if (dto == null)
- return new ScanResult(false, "Invalid response from central server.", 0);
-
- return new ScanResult(
- dto.Success,
- dto.Message ?? string.Empty,
- dto.CooldownSecondsRemaining,
- dto.Employee?.ToModel(),
- (MealSession)dto.MealSession,
- dto.EmployeeSiteId,
- dto.CurrentSiteId);
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.ProcessScanDetailed");
- return new ScanResult(false, "Cannot reach central server: " + ex.Message, 0);
- }
- }
-
- public ScanRecord? GetLastScan()
- {
- if (string.IsNullOrWhiteSpace(BaseUrl))
- return null;
- try
- {
- var url = $"{BaseUrl}/api/rfid/scans/last";
- var dto = _http.GetFromJsonAsync(url, JsonOptions).GetAwaiter().GetResult();
- return dto?.ToEntity();
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetLastScan");
- return null;
- }
- }
-
- public IReadOnlyList GetLastScans(int count)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl) || count <= 0)
- return Array.Empty();
- try
- {
- var url = $"{BaseUrl}/api/rfid/scans/recent?count={count}";
- var list = _http.GetFromJsonAsync>(url, JsonOptions).GetAwaiter().GetResult();
- return list?.Select(d => d.ToEntity()).ToList() ?? new List();
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetLastScans");
- return Array.Empty();
- }
- }
-
- public IReadOnlyList GetScansForToday()
- {
- if (string.IsNullOrWhiteSpace(BaseUrl))
- return Array.Empty();
- try
- {
- var url = $"{BaseUrl}/api/rfid/scans/today";
- var list = _http.GetFromJsonAsync>(url, JsonOptions).GetAwaiter().GetResult();
- return list?.Select(d => d.ToEntity()).ToList() ?? new List();
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetScansForToday");
- return Array.Empty();
- }
- }
-
- public int GetTodayScanCount() => GetTodayScanCountAsync().GetAwaiter().GetResult();
-
- public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl))
- return 0;
- try
- {
- var url = $"{BaseUrl}/api/rfid/stats/today";
- var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
- return wrap?.Count ?? 0;
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetTodayScanCountAsync");
- return 0;
- }
- }
-
- public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl))
- return 0;
- try
- {
- var url = $"{BaseUrl}/api/rfid/stats/total";
- var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
- return wrap?.Count ?? 0;
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetTotalScanCountAsync");
- return 0;
- }
- }
-
- public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(cardId))
- return 0;
- try
- {
- var url = $"{BaseUrl}/api/rfid/stats/total-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}";
- var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
- return wrap?.Count ?? 0;
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetTotalScanCountForCardAsync");
- return 0;
- }
- }
-
- public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(cardId))
- return 0;
- try
- {
- var url = $"{BaseUrl}/api/rfid/stats/today-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}";
- var wrap = await _http.GetFromJsonAsync(url, JsonOptions, cancellationToken).ConfigureAwait(false);
- return wrap?.Count ?? 0;
- }
- catch (Exception ex)
- {
- Logger.Log(ex, "ClientRfidService.GetTodayScanCountForCardAsync");
- return 0;
- }
- }
-
- public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice)
- {
- // Server owns SQLite; client has nothing to update locally.
- }
-
- private static string Truncate(string s, int max)
- {
- if (string.IsNullOrEmpty(s) || s.Length <= max)
- return s;
- return s.Substring(0, max) + "…";
- }
-
- private sealed class CountDto
- {
- public int Count { get; set; }
- }
-}
diff --git a/Services/ICanteenBackendApiClient.cs b/Services/ICanteenBackendApiClient.cs
new file mode 100644
index 0000000..78eac6c
--- /dev/null
+++ b/Services/ICanteenBackendApiClient.cs
@@ -0,0 +1,29 @@
+using UtopiaCanteen.Shared;
+
+namespace UtopiaCanteenSystem.Services;
+
+///
+/// HTTP client for the central canteen backend API (scanner UI and settings).
+///
+public interface ICanteenBackendApiClient : IRfidService
+{
+ Task HealthCheckAsync(CancellationToken cancellationToken = default);
+
+ Task SyncCacheNowAsync(CancellationToken cancellationToken = default);
+
+ Task SyncOrdersNowAsync(CancellationToken cancellationToken = default);
+
+ Task GetCacheStatusAsync(CancellationToken cancellationToken = default);
+
+ Task GetHealthAsync(CancellationToken cancellationToken = default);
+
+ Task> GetMealSchedulesAsync(CancellationToken cancellationToken = default);
+
+ Task CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default);
+
+ Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default);
+
+ Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default);
+
+ Task GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default);
+}