feat: implement hourly rates for tables/rooms, add shop publishing, and introduce system health checks.

This commit is contained in:
Ho Ngoc Hai
2026-03-05 12:09:28 +07:00
parent c70248fdec
commit 91a219d65f
20 changed files with 336 additions and 67 deletions

View File

@@ -191,18 +191,18 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// EN: Restore session from localStorage on first render (Blazor WASM ready)
// VI: Khôi phục session từ localStorage khi render lần đầu (Blazor WASM sẵn sàng)
await AuthSvc.TryRestoreSessionAsync();
}
// EN: Session restore is handled by AdminBase.OnInitializedAsync() — no need to duplicate here.
// VI: Khôi phục session được xử lý bởi AdminBase.OnInitializedAsync() — không cần gọi lại ở đây.
// EN: Re-init Lucide icons after every render (Blazor navigation replaces DOM)
// VI: Khởi tạo lại Lucide icons sau mỗi lần render (Blazor navigation thay đổi DOM)
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private void OnAuthStateChanged() => InvokeAsync(StateHasChanged);
private void OnAuthStateChanged()
{
try { InvokeAsync(StateHasChanged); } catch { }
}
private void RecoverError() => _errorBoundary?.Recover();

View File

@@ -115,6 +115,7 @@
{
@foreach (var shop in _shops)
{
var stats = _shopStats.FirstOrDefault(s => s.ShopId == shop.Id);
<a href="/admin/shop/@shop.Id/overview" class="admin-store-card" style="text-decoration:none;color:inherit;cursor:pointer;">
<div class="admin-store-card__top">
<div class="admin-store-card__info">
@@ -126,11 +127,43 @@
<div class="admin-store-card__type">@ShopSidebarConfig.GetVerticalLabel(shop.Category) • @(shop.Slug)</div>
</div>
</div>
<div class="admin-status-badge admin-status-badge--@(shop.Status == "active" ? "online" : "setup")">
<span class="admin-status-badge__dot"></span>
@(shop.Status == "active" ? L["Dashboard_Status_Open"] : L["Dashboard_Status_Setup"])
<div style="display:flex;align-items:center;gap:8px;">
@if (shop.Status != "active")
{
<button class="admin-btn-primary" style="font-size:12px;padding:6px 12px;"
@onclick:preventDefault @onclick:stopPropagation
@onclick="@(() => PublishShopAsync(shop.Id))">
<i data-lucide="check-circle" style="width:14px;height:14px;"></i>
<span>@L["Dashboard_FinishSetup"]</span>
</button>
}
<div class="admin-status-badge admin-status-badge--@(shop.Status == "active" ? "online" : "setup")">
<span class="admin-status-badge__dot"></span>
@(shop.Status == "active" ? L["Dashboard_Status_Open"] : L["Dashboard_Status_Setup"])
</div>
</div>
</div>
@if (stats != null)
{
<div style="display:flex;gap:16px;padding-top:10px;border-top:1px solid var(--admin-border-subtle);margin-top:10px;">
<div style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--admin-text-tertiary);">
<i data-lucide="trending-up" style="width:12px;height:12px;color:#22C55E;"></i>
@FormatCurrency(stats.Revenue)
</div>
<div style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--admin-text-tertiary);">
<i data-lucide="shopping-bag" style="width:12px;height:12px;color:#3B82F6;"></i>
@stats.OrderCount @L["Dashboard_Stats_Orders"]
</div>
<div style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--admin-text-tertiary);">
<i data-lucide="users" style="width:12px;height:12px;color:#8B5CF6;"></i>
@stats.StaffCount @L["Dashboard_Stats_Staff"]
</div>
<div style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--admin-text-tertiary);">
<i data-lucide="package" style="width:12px;height:12px;color:#EC4899;"></i>
@stats.ProductCount @L["Dashboard_Stats_Products"]
</div>
</div>
}
</a>
}
}
@@ -153,9 +186,9 @@
<span>@L["Dashboard_QA_CreateStore"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/system/audit" class="admin-quick-action">
<i data-lucide="settings" style="color:#3B82F6;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_SystemSettings"]</span>
<a href="/admin/users" class="admin-quick-action">
<i data-lucide="users" style="color:#3B82F6;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_ManageStaff"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/roles" class="admin-quick-action">
@@ -163,6 +196,26 @@
<span>@L["Dashboard_QA_Permissions"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/system/devices" class="admin-quick-action">
<i data-lucide="monitor-smartphone" style="color:#F59E0B;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_Devices"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/system/integrations" class="admin-quick-action">
<i data-lucide="plug" style="color:#06B6D4;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_Integrations"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/system/audit" class="admin-quick-action">
<i data-lucide="scroll-text" style="color:#EC4899;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_AuditLog"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
<a href="/admin/settings" class="admin-quick-action">
<i data-lucide="settings" style="color:#6B7280;width:18px;height:18px;"></i>
<span>@L["Dashboard_QA_SystemSettings"]</span>
<i data-lucide="chevron-right" style="margin-left:auto;width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</a>
</div>
</div>
@@ -173,26 +226,45 @@
<i data-lucide="activity" style="color:#22C55E;"></i>
@L["Dashboard_SystemStatus"]
</h3>
<button class="admin-icon-btn" title="@L["Dashboard_RefreshStatus"]" @onclick="RefreshHealthAsync" disabled="@_healthLoading" style="font-size:12px;">
<i data-lucide="refresh-cw" style="width:14px;height:14px;@(_healthLoading ? "animation:spin 1s linear infinite;" : "")"></i>
</button>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">API Gateway</span>
<span style="font-size:13px;color:#22C55E;display:flex;align-items:center;gap:4px;">
<span style="width:6px;height:6px;border-radius:50%;background:#22C55E;display:inline-block;"></span> Online
</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">IAM Service</span>
<span style="font-size:13px;color:#22C55E;display:flex;align-items:center;gap:4px;">
<span style="width:6px;height:6px;border-radius:50%;background:#22C55E;display:inline-block;"></span> Online
</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Merchant Service</span>
<span style="font-size:13px;color:#22C55E;display:flex;align-items:center;gap:4px;">
<span style="width:6px;height:6px;border-radius:50%;background:#22C55E;display:inline-block;"></span> Online
</span>
</div>
@if (_healthLoading && _serviceHealth.Count == 0)
{
<div style="text-align:center;padding:16px;color:var(--admin-text-tertiary);font-size:13px;">
@L["Dashboard_CheckingServices"]
</div>
}
else
{
@foreach (var svc in _serviceHealth)
{
var statusColor = svc.IsOnline ? "#22C55E" : "#EF4444";
var statusText = svc.IsOnline ? "Online" : "Offline";
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;display:flex;align-items:center;gap:8px;">
<i data-lucide="@svc.Icon" style="width:14px;height:14px;"></i>
@svc.Name
</span>
<span style="font-size:13px;color:@statusColor;display:flex;align-items:center;gap:4px;">
@if (svc.IsOnline && svc.LatencyMs.HasValue)
{
<span style="font-size:11px;color:var(--admin-text-tertiary);margin-right:4px;">@(svc.LatencyMs)ms</span>
}
<span style="width:6px;height:6px;border-radius:50%;background:@statusColor;display:inline-block;"></span>
@statusText
</span>
</div>
}
@if (_healthCheckedAt.HasValue)
{
<div style="font-size:11px;color:var(--admin-text-tertiary);text-align:right;margin-top:4px;">
@L["Dashboard_LastChecked"] @_healthCheckedAt.Value.ToString("HH:mm:ss")
</div>
}
}
</div>
</div>
</div>
@@ -201,6 +273,10 @@
@code {
private List<PosDataService.ShopInfo> _shops = new();
private List<PosDataService.ShopStatsInfo> _shopStats = new();
private List<PosDataService.ServiceHealthInfo> _serviceHealth = new();
private bool _healthLoading = false;
private DateTime? _healthCheckedAt;
protected override async Task OnInitializedAsync()
{
@@ -208,12 +284,17 @@
IsLoading = true;
try
{
_shops = await DataService.GetShopsAsync();
var shopsTask = DataService.GetShopsAsync();
var statsTask = DataService.GetShopStatsAsync();
var healthTask = LoadHealthAsync();
_shops = await shopsTask;
try { _shopStats = await statsTask; } catch { _shopStats = new(); }
await healthTask;
}
catch (Exception ex)
{
_shops = new();
Console.Error.WriteLine($"[Dashboard] Error loading shops: {ex}");
Console.Error.WriteLine($"[Dashboard] Error loading data: {ex}");
}
finally
{
@@ -221,4 +302,37 @@
}
}
private async Task PublishShopAsync(Guid shopId)
{
var ok = await DataService.PublishShopAsync(shopId);
if (ok)
{
_shops = await DataService.GetShopsAsync();
StateHasChanged();
}
}
private async Task LoadHealthAsync()
{
_healthLoading = true;
try
{
_serviceHealth = await DataService.CheckServicesHealthAsync();
_healthCheckedAt = DateTime.Now;
}
catch
{
_serviceHealth = new();
}
finally
{
_healthLoading = false;
}
}
private async Task RefreshHealthAsync()
{
await LoadHealthAsync();
StateHasChanged();
}
}

View File

@@ -148,7 +148,7 @@ else if (SubSection == "rooms")
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang hát: @_tables.Count(t => t.Status == "occupied")</span>
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 8; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 8; _newTableZone = ""; _newHourlyRate = 0; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm phòng
</button>
</div>
@@ -157,10 +157,11 @@ else if (SubSection == "rooms")
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingTableId.HasValue ? "Chỉnh sửa phòng" : "Thêm phòng mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số phòng *</label><input type="text" @bind="_newTableNumber" placeholder="VD: 01, A1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newTableCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại phòng</label><select @bind="_newTableZone" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);"><option value="Standard">Standard</option><option value="VIP">VIP</option><option value="Party">Party</option><option value="Deluxe">Deluxe</option></select></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giá/giờ (VND)</label><input type="number" @bind="_newHourlyRate" min="0" step="10000" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="@(_editingTableId.HasValue ? SaveTable : AddTable)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingTableId.HasValue ? "Cập nhật" : "Lưu")</button>
@@ -183,7 +184,7 @@ else if (SubSection == "rooms")
var borderColor = room.Status switch { "available" => "rgba(139,92,246,0.3)", "occupied" => "rgba(239,68,68,0.3)", "reserved" => "rgba(245,158,11,0.3)", _ => "rgba(107,107,111,0.3)" };
var statusColor = room.Status switch { "available" => "#8B5CF6", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" };
var statusText = room.Status switch { "available" => "Trống", "occupied" => "Đang hát", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => room.Status };
var roomType = (room.Zone ?? "").ToLower() switch { var z when z.Contains("vip") => ("VIP", "#F59E0B", 200000m), var z when z.Contains("party") => ("Party", "#EC4899", 350000m), _ => ("Standard", "#8B5CF6", 120000m) };
var roomType = (room.Zone ?? "").ToLower() switch { var z when z.Contains("vip") => ("VIP", "#F59E0B"), var z when z.Contains("party") => ("Party", "#EC4899"), _ => ("Standard", "#8B5CF6") };
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;position:relative;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick='() => ShowQrModal(room)' style="background:rgba(139,92,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Mã QR"><i data-lucide="qr-code" style="color:#8B5CF6;width:12px;height:12px;"></i></button>
@@ -195,7 +196,7 @@ else if (SubSection == "rooms")
<span style="font-size:18px;font-weight:700;">Phòng @room.TableNumber</span>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(room.Zone ?? "Standard") • @room.Capacity chỗ</div>
<div style="font-size:12px;font-weight:600;color:var(--admin-orange-primary);margin-bottom:8px;">@ShopHelpers.FormatVND(roomType.Item3)/giờ</div>
<div style="font-size:12px;font-weight:600;color:var(--admin-orange-primary);margin-bottom:8px;">@ShopHelpers.FormatVND(room.HourlyRate)/giờ</div>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
<span style="width:6px;height:6px;border-radius:50%;background:@statusColor;"></span>
@statusText
@@ -204,7 +205,7 @@ else if (SubSection == "rooms")
{
var elapsed = DateTime.UtcNow - (room.StartedAt ?? DateTime.UtcNow);
var hours = Math.Max(1, (int)Math.Ceiling(elapsed.TotalHours));
var bill = hours * roomType.Item3;
var bill = hours * room.HourlyRate;
<div style="margin-top:8px;padding-top:8px;border-top:1px solid @borderColor;">
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:4px;">@room.GuestCount khách • Bắt đầu @(room.StartedAt?.ToString("HH:mm") ?? "—")</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
@@ -349,6 +350,7 @@ else if (SubSection == "zones")
private string _newTableNumber = "";
private int _newTableCapacity = 4;
private string _newTableZone = "";
private decimal _newHourlyRate;
private string? _tableFormMessage;
private bool _tableFormSuccess;
// Zone form state
@@ -431,6 +433,7 @@ else if (SubSection == "zones")
_newTableNumber = table.TableNumber;
_newTableCapacity = table.Capacity;
_newTableZone = table.Zone ?? "";
_newHourlyRate = table.HourlyRate;
_tableFormMessage = null;
_showTableForm = true;
}
@@ -444,9 +447,9 @@ else if (SubSection == "zones")
}
try
{
await DataService.CreateTableAsync(new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone));
await DataService.CreateTableAsync(new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone, _newHourlyRate > 0 ? _newHourlyRate : null));
_tableFormMessage = $"Đã thêm bàn '{_newTableNumber}' thành công!"; _tableFormSuccess = true;
_newTableNumber = ""; _newTableCapacity = 4; _newTableZone = "";
_newTableNumber = ""; _newTableCapacity = 4; _newTableZone = ""; _newHourlyRate = 0;
if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId);
}
catch (Exception ex) { _tableFormMessage = $"Lỗi: {ex.Message}"; _tableFormSuccess = false; }
@@ -461,7 +464,7 @@ else if (SubSection == "zones")
}
try
{
await DataService.UpdateTableAsync(_editingTableId.Value, new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone));
await DataService.UpdateTableAsync(_editingTableId.Value, new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone, _newHourlyRate > 0 ? _newHourlyRate : null));
_tableFormMessage = $"Đã cập nhật bàn '{_newTableNumber}' thành công!"; _tableFormSuccess = true;
_editingTableId = null;
if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId);

View File

@@ -143,16 +143,28 @@
</div>
<div class="pos-cart-footer">
@{
var totalFnb = _activeOrders
.Where(o => o.TableId.ToString() == SelectedRoom?.Id)
.Sum(o => o.TotalAmount);
var roomCost = 0m;
if (SelectedRoom!.HourlyRate > 0 && SelectedRoom.SessionStart.HasValue)
{
var elapsed = DateTime.Now - SelectedRoom.SessionStart.Value;
roomCost = Math.Ceiling((decimal)elapsed.TotalMinutes / 60m) * SelectedRoom.HourlyRate;
}
}
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px;">
<span style="color:var(--pos-text-secondary);">Tiền phòng</span>
<span>@FormatPrice(roomCost)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px;">
<span style="color:var(--pos-text-secondary);">Tiền F&B</span>
<span>@FormatPrice(totalFnb)</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng F&B</span>
<span class="pos-cart-total__value">
@{
var totalFnb = _activeOrders
.Where(o => o.TableId.ToString() == SelectedRoom?.Id)
.Sum(o => o.TotalAmount);
}
@FormatPrice(totalFnb)
</span>
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(roomCost + totalFnb)</span>
</div>
@if (SelectedRoom.Status == "available")
{
@@ -212,7 +224,8 @@
t.Status,
t.Zone ?? "Standard",
t.Zone ?? "Tầng 1",
t.StartedAt
t.StartedAt,
t.HourlyRate
)).ToList();
var zoneNames = _rooms.Select(r => r.Zone).Distinct().ToList();
@@ -243,5 +256,5 @@
"reserved" => "Đã đặt", "cleaning" => "Đang dọn", _ => status
};
private record RoomInfo(string Id, string Name, int Capacity, string Status, string Type, string Zone, DateTime? SessionStart);
private record RoomInfo(string Id, string Name, int Capacity, string Status, string Type, string Zone, DateTime? SessionStart, decimal HourlyRate = 0);
}

View File

@@ -62,10 +62,18 @@
<span style="color:var(--pos-text-secondary);">Loại phòng</span>
<span>@(_room?.Type ?? "-")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Giá/giờ</span>
<span>@FormatPrice(_room?.HourlyRate ?? 0)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Thời gian</span>
<span>@_elapsed.ToString(@"h\:mm") giờ</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;font-weight:700;padding-top:8px;border-top:1px solid var(--pos-border-subtle);">
<span>Tiền phòng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(RoomCost)</span>
</div>
</div>
@* ═══ ACTION BUTTONS / NÚT HÀNH ĐỘNG ═══ *@
@@ -130,13 +138,17 @@
</div>
<div class="pos-cart-footer">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tiền phòng</span>
<span>@FormatPrice(RoomCost)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px;">
<span style="color:var(--pos-text-secondary);">Tiền F&B</span>
<span>@FormatPrice(_fnbItems.Sum(i => i.Price * i.Qty))</span>
<span>@FormatPrice(FnbTotal)</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(_fnbItems.Sum(i => i.Price * i.Qty))</span>
<span class="pos-cart-total__value">@FormatPrice(RoomCost + FnbTotal)</span>
</div>
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo($"karaoke/order-fnb/{RoomId}"))">
<i data-lucide="plus" style="width:18px;height:18px;"></i> Gọi thêm F&B
@@ -158,6 +170,11 @@
private TimeSpan _elapsed;
private System.Threading.Timer? _timer;
private decimal RoomCost => (_room?.HourlyRate ?? 0) > 0
? Math.Ceiling((decimal)_elapsed.TotalMinutes / 60m) * _room!.HourlyRate
: 0;
private decimal FnbTotal => _fnbItems.Sum(i => i.Price * i.Qty);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
@@ -173,7 +190,7 @@
if (table != null)
{
_room = new RoomInfo(table.Id, table.TableNumber, table.Capacity,
table.Zone ?? "Standard", table.Zone ?? "Tầng 1", table.StartedAt);
table.Zone ?? "Standard", table.Zone ?? "Tầng 1", table.StartedAt, table.HourlyRate);
}
else
{
@@ -232,6 +249,6 @@
_timer?.Dispose();
}
private record RoomInfo(Guid Id, string Name, int Capacity, string Type, string Zone, DateTime? SessionStart);
private record RoomInfo(Guid Id, string Name, int Capacity, string Type, string Zone, DateTime? SessionStart, decimal HourlyRate = 0);
private record FnbItem(string Name, decimal Price, int Qty);
}

View File

@@ -11,6 +11,9 @@ public class AuthStateService
public void Login(string email, string token, string role)
{
if (IsAuthenticated && Token == token && UserEmail == email)
return;
IsAuthenticated = true;
UserEmail = email;
Token = token;

View File

@@ -140,7 +140,7 @@ public class PosDataService
public string? Category => CategoryName;
}
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, string? ImageUrl = null);
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, int? PositionX = null, int? PositionY = null, string? QrToken = null);
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, decimal HourlyRate = 0, int? PositionX = null, int? PositionY = null, string? QrToken = null);
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
public record ShopAssignmentInfo(Guid ShopId, string? ShopRole, Guid? BranchId);
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName,
@@ -789,7 +789,7 @@ public class PosDataService
// ═══ TABLES CRUD ═══
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone);
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone, decimal? HourlyRate = null);
public async Task<bool> CreateTableAsync(CreateTableRequest req)
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _writeOptions); return r.IsSuccessStatusCode; }
@@ -1120,4 +1120,48 @@ public class PosDataService
var resp = await _http.PostAsJsonAsync($"api/bff/sessions/{sessionId}/close", new { }, _writeOptions);
return resp.IsSuccessStatusCode;
}
// ═══ SHOP PUBLISH (draft → active) ═══
public async Task<bool> PublishShopAsync(Guid shopId)
{
AttachToken();
var resp = await _http.PostAsync($"api/bff/shops/{shopId}/publish", null);
return resp.IsSuccessStatusCode;
}
// ═══ SERVICE HEALTH CHECK ═══
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);
private static readonly (string Name, string Icon, string HealthPath)[] _healthEndpoints = new[]
{
("IAM Service", "shield", "api/iam/health"),
("Merchant Service", "store", "api/merchants/health"),
("Catalog Service", "package", "api/catalog/health"),
("Order Service", "shopping-bag", "api/orders/health"),
};
public async Task<List<ServiceHealthInfo>> CheckServicesHealthAsync()
{
var results = new List<ServiceHealthInfo>();
var tasks = _healthEndpoints.Select(async svc =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var resp = await _http.GetAsync(svc.HealthPath, cts.Token);
sw.Stop();
return new ServiceHealthInfo(svc.Name, svc.Icon, resp.IsSuccessStatusCode, (int)sw.ElapsedMilliseconds);
}
catch
{
sw.Stop();
return new ServiceHealthInfo(svc.Name, svc.Icon, false, null);
}
}).ToArray();
return (await Task.WhenAll(tasks)).ToList();
}
}

View File

@@ -99,6 +99,15 @@
<span class="dismiss" style="cursor: pointer; margin-left: 1rem;"></span>
</div>
<script>
window.addEventListener('error', function(e) {
console.warn('[BLAZOR-DEBUG] Error:', e.message, 'at', e.filename, ':', e.lineno);
});
window.addEventListener('unhandledrejection', function(e) {
console.warn('[BLAZOR-DEBUG] Unhandled rejection:', e.reason);
});
</script>
<!-- EN: Blazor WebAssembly -->
<script src="/_framework/blazor.webassembly.js"></script>

View File

@@ -420,9 +420,20 @@
"Dashboard_CreateStoreNow": "Create Store Now",
"Dashboard_QuickActions": "Quick Actions",
"Dashboard_QA_CreateStore": "Create new store",
"Dashboard_QA_SystemSettings": "System Settings",
"Dashboard_QA_ManageStaff": "Manage Staff",
"Dashboard_QA_Permissions": "Permissions",
"Dashboard_QA_Devices": "Device Management",
"Dashboard_QA_Integrations": "Third-party Integrations",
"Dashboard_QA_AuditLog": "Activity Log",
"Dashboard_QA_SystemSettings": "System Settings",
"Dashboard_SystemStatus": "System Status",
"Dashboard_RefreshStatus": "Refresh",
"Dashboard_CheckingServices": "Checking services...",
"Dashboard_LastChecked": "Last checked at",
"Dashboard_FinishSetup": "Finish Setup",
"Dashboard_Stats_Orders": "orders",
"Dashboard_Stats_Staff": "staff",
"Dashboard_Stats_Products": "products",
"Dashboard_Status_Open": "Open",
"Dashboard_Status_Setup": "Setting up"
}

View File

@@ -420,9 +420,20 @@
"Dashboard_CreateStoreNow": "Tạo cửa hàng ngay",
"Dashboard_QuickActions": "Thao tác nhanh",
"Dashboard_QA_CreateStore": "Tạo cửa hàng mới",
"Dashboard_QA_SystemSettings": "Cài đặt hệ thống",
"Dashboard_QA_ManageStaff": "Quản lý nhân viên",
"Dashboard_QA_Permissions": "Phân quyền",
"Dashboard_QA_Devices": "Quản lý thiết bị",
"Dashboard_QA_Integrations": "Tích hợp bên thứ ba",
"Dashboard_QA_AuditLog": "Nhật ký hoạt động",
"Dashboard_QA_SystemSettings": "Cài đặt hệ thống",
"Dashboard_SystemStatus": "Trạng thái hệ thống",
"Dashboard_RefreshStatus": "Làm mới",
"Dashboard_CheckingServices": "Đang kiểm tra dịch vụ...",
"Dashboard_LastChecked": "Cập nhật lúc",
"Dashboard_FinishSetup": "Hoàn thành thiết lập",
"Dashboard_Stats_Orders": "đơn",
"Dashboard_Stats_Staff": "NV",
"Dashboard_Stats_Products": "SP",
"Dashboard_Status_Open": "Đang mở",
"Dashboard_Status_Setup": "Thiết lập"
}

View File

@@ -67,6 +67,14 @@ public class ShopController : ControllerBase
public Task<IActionResult> GetShopStats() =>
_merchant.GetAsync("/api/v1/shops/stats").ProxyAsync();
/// <summary>
/// EN: Publish a shop (draft → active).
/// VI: Xuất bản cửa hàng (draft → active).
/// </summary>
[HttpPost("shops/{shopId:guid}/publish")]
public Task<IActionResult> PublishShop(Guid shopId) =>
_merchant.PostAsync($"/api/v1/shops/{shopId}/publish", null).ProxyAsync();
/// <summary>
/// EN: Get device tokens registered for this merchant's staff.
/// VI: Lấy danh sách device token đã đăng ký cho nhân viên của merchant.

View File

@@ -13,7 +13,8 @@ public record CreateTableCommand(
Guid ShopId,
string TableNumber,
int Capacity,
string? Zone = null
string? Zone = null,
decimal? HourlyRate = null
) : IRequest<CreateTableResult>;
/// <summary>

View File

@@ -48,6 +48,9 @@ public class CreateTableCommandHandler : IRequestHandler<CreateTableCommand, Cre
request.Capacity,
request.Zone);
if (request.HourlyRate.HasValue && request.HourlyRate.Value > 0)
table.SetHourlyRate(request.HourlyRate.Value);
await _tableRepository.AddAsync(table, cancellationToken);
await _tableRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

View File

@@ -14,5 +14,6 @@ public record UpdateTableCommand(
int? Capacity = null,
string? Zone = null,
int? PositionX = null,
int? PositionY = null
int? PositionY = null,
decimal? HourlyRate = null
) : IRequest<bool>;

View File

@@ -34,6 +34,9 @@ public class UpdateTableCommandHandler : IRequestHandler<UpdateTableCommand, boo
if (request.PositionX.HasValue && request.PositionY.HasValue)
table.SetPosition(request.PositionX.Value, request.PositionY.Value);
if (request.HourlyRate.HasValue)
table.SetHourlyRate(request.HourlyRate.Value);
_tableRepository.Update(table);
await _tableRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

View File

@@ -22,6 +22,7 @@ public record TableDto(
int Capacity,
string? Zone,
string Status,
decimal HourlyRate = 0,
int? PositionX = null,
int? PositionY = null,
string? QrToken = null

View File

@@ -34,6 +34,7 @@ public class GetTablesQueryHandler : IRequestHandler<GetTablesQuery, IEnumerable
t.Capacity,
t.Zone,
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available",
t.HourlyRate,
t.PositionX,
t.PositionY,
t.QrToken

View File

@@ -59,7 +59,8 @@ public class TablesController : ControllerBase
request.ShopId,
request.TableNumber,
request.Capacity,
request.Zone);
request.Zone,
request.HourlyRate);
var result = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetTables), new { shopId = request.ShopId },
@@ -83,7 +84,8 @@ public class TablesController : ControllerBase
request.Capacity,
request.Zone,
request.PositionX,
request.PositionY);
request.PositionY,
request.HourlyRate);
var result = await _mediator.Send(command, ct);
return Ok(new ApiResponse<bool> { Success = true, Data = result });
@@ -158,7 +160,8 @@ public record CreateTableRequest(
Guid ShopId,
string TableNumber,
int Capacity,
string? Zone = null);
string? Zone = null,
decimal? HourlyRate = null);
/// <summary>
/// EN: Request to update table details.
@@ -168,7 +171,8 @@ public record UpdateTableRequest(
int? Capacity = null,
string? Zone = null,
int? PositionX = null,
int? PositionY = null);
int? PositionY = null,
decimal? HourlyRate = null);
/// <summary>
/// EN: Request to change table status.

View File

@@ -20,6 +20,7 @@ public class Table : Entity, IAggregateRoot
private int? _positionX;
private int? _positionY;
private string? _qrToken;
private decimal _hourlyRate;
private DateTime _createdAt;
private DateTime? _updatedAt;
@@ -27,11 +28,12 @@ public class Table : Entity, IAggregateRoot
public string TableNumber => _tableNumber;
public int Capacity => _capacity;
public string? Zone => _zone;
public TableStatus Status => _status;
public TableStatus Status => _status ?? Enumeration.FromValue<TableStatus>(StatusId);
public int StatusId { get; private set; }
public int? PositionX => _positionX;
public int? PositionY => _positionY;
public string? QrToken => _qrToken;
public decimal HourlyRate => _hourlyRate;
public DateTime CreatedAt => _createdAt;
public DateTime? UpdatedAt => _updatedAt;
@@ -58,8 +60,14 @@ public class Table : Entity, IAggregateRoot
_createdAt = DateTime.UtcNow;
}
private void EnsureStatusLoaded()
{
_status ??= Enumeration.FromValue<TableStatus>(StatusId);
}
public void MarkAsOccupied()
{
EnsureStatusLoaded();
if (_status != TableStatus.Available && _status != TableStatus.Reserved)
throw new DomainException($"Cannot occupy table with status {_status.Name}");
@@ -70,6 +78,7 @@ public class Table : Entity, IAggregateRoot
public void MarkAsAvailable()
{
EnsureStatusLoaded();
_status = TableStatus.Available;
StatusId = TableStatus.Available.Id;
_updatedAt = DateTime.UtcNow;
@@ -77,11 +86,18 @@ public class Table : Entity, IAggregateRoot
public void MarkAsCleaning()
{
EnsureStatusLoaded();
_status = TableStatus.Cleaning;
StatusId = TableStatus.Cleaning.Id;
_updatedAt = DateTime.UtcNow;
}
public void SetHourlyRate(decimal rate)
{
_hourlyRate = rate;
_updatedAt = DateTime.UtcNow;
}
public void SetPosition(int x, int y)
{
_positionX = x;

View File

@@ -58,6 +58,12 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration<Table>
.HasColumnName("qr_token")
.HasMaxLength(64);
builder.Property(t => t.HourlyRate)
.HasField("_hourlyRate")
.HasColumnName("hourly_rate")
.HasPrecision(18, 2)
.HasDefaultValue(0m);
builder.Property(t => t.CreatedAt)
.HasField("_createdAt")
.HasColumnName("created_at")