feat: implement hourly rates for tables/rooms, add shop publishing, and introduce system health checks.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user