diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index fe8ee8dd..efaf0638 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -642,6 +642,10 @@ // ═══ FINANCE ═══ case "finance": +
+ + Đơn hàng theo cửa hàng · Ví tiền chung cho tài khoản +
var finOrders = _financePeriod switch { "7d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-7)).ToList(), "30d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30)).ToList(), @@ -885,6 +889,10 @@ // ═══ CUSTOMERS + MEMBERSHIP LEVELS ═══ case "customers": +
+ + Dữ liệu khách hàng chung cho tất cả cửa hàng trong thương hiệu +
@foreach (var (tab, label) in new[] { ("members","Khách hàng"), ("levels","Cấp bậc"), ("exp","Điểm EXP") }) { @@ -1206,7 +1214,7 @@
-
+
@@ -1896,6 +1904,10 @@ // ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══ case "promotions": +
+ + Chiến dịch khuyến mãi chung cho tất cả cửa hàng trong thương hiệu +
@* ─── Sub-tabs: Campaigns | Vouchers ─── *@
@{ var promoTabs = new[] { ("campaigns", "Chiến dịch", "tag"), ("vouchers", "Mã voucher", "ticket") }; } @@ -2387,7 +2399,9 @@ // ═══ R4: ZONES / KHU VỰC (Nhà hàng) ═══ case "zones": - var zoneGroups = _tables.GroupBy(t => t.Zone ?? "Chung").Select((g, i) => new { Name = g.Key, Count = g.Count(), Color = _zoneColors[i % _zoneColors.Length], Icon = _zoneIcons[i % _zoneIcons.Length] }).OrderBy(z => z.Name).ToList(); + var tableZones = _tables.GroupBy(t => t.Zone ?? "Chung").Select(g => new { Name = g.Key, Count = g.Count() }).ToList(); + var allZones = tableZones.Select(z => z.Name).Union(_customZones).Distinct().OrderBy(z => z).ToList(); + var zoneGroups = allZones.Select((z, i) => new { Name = z, Count = tableZones.FirstOrDefault(tz => tz.Name == z)?.Count ?? 0, Color = _zoneColors[i % _zoneColors.Length], Icon = _zoneIcons[i % _zoneIcons.Length] }).ToList();

Quản lý khu vực

@@ -2408,9 +2422,9 @@ @if (_zoneFormMessage != null) {
@_zoneFormMessage
}
} - @if (!_tables.Any()) + @if (!zoneGroups.Any()) { - @RenderEmpty("map-pin", "#F59E0B", "Chưa có khu vực nào", "Thêm bàn với khu vực trong phần Quản lý Bàn trước", "grid-3x3", "Quản lý Bàn", $"/admin/shop/{ShopId}/tables") + @RenderEmpty("map-pin", "#F59E0B", "Chưa có khu vực nào", "Nhấn 'Thêm khu vực' ở trên để tạo khu vực đầu tiên", "", "", "") } else { @@ -2800,8 +2814,11 @@ private string? _editingZoneOriginalName; private string? _zoneFormMessage; private bool _zoneFormSuccess; + private readonly List _customZones = new(); private static readonly string[] _zoneColors = { "#3B82F6", "#A855F7", "#22C55E", "#F59E0B", "#EC4899", "#6366F1" }; private static readonly string[] _zoneIcons = { "building", "crown", "trees", "wine", "coffee", "map-pin" }; + private List AllZoneNames => _tables.Select(t => t.Zone ?? "Chung").Distinct() + .Union(_customZones).Distinct().OrderBy(z => z).ToList(); // Kitchen state private List _kitchenTickets = new(); private string _kitchenStatusFilter = "all"; @@ -3632,10 +3649,13 @@ } else { - // Add: the zone name will be available when creating tables - _zoneFormMessage = $"Khu vực '{_newZoneName.Trim()}' đã sẵn sàng. Hãy thêm bàn với khu vực này trong phần Quản lý Bàn."; _zoneFormSuccess = true; - _newTableZone = _newZoneName.Trim(); + var zoneName = _newZoneName.Trim(); + if (!_customZones.Contains(zoneName) && !_tables.Any(t => (t.Zone ?? "Chung") == zoneName)) + _customZones.Add(zoneName); + _zoneFormMessage = $"Đã thêm khu vực '{zoneName}'."; _zoneFormSuccess = true; + _newTableZone = zoneName; _newZoneName = ""; + _showZoneForm = false; } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantDesktop.razor index d513fc50..19a75035 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantDesktop.razor @@ -1,6 +1,6 @@ @* - EN: Restaurant POS Desktop — Table map grid + order panel with full payment flow. - VI: POS Nhà hàng Desktop — Lưới sơ đồ bàn + panel đặt món với luồng thanh toán đầy đủ. + EN: Restaurant POS Desktop — Table map + dine-in order flow (order → kitchen → add more → pay). + VI: POS Nhà hàng Desktop — Sơ đồ bàn + luồng dine-in (gọi món → bếp → gọi thêm → thanh toán). *@ @page "/pos/{ShopId:guid}/restaurant" @layout PosLayout @@ -49,15 +49,30 @@
@foreach (var table in FilteredTables) { + var effectiveStatus = GetEffectiveStatus(table); + var hasReservation = GetTableReservation(table) is not null;
+ transition:all .2s ease;position:relative;">
@table.Name
@table.Seats chỗ
- @GetStatusLabel(table.Status) + @GetStatusLabel(effectiveStatus)
+ @if (hasReservation) + { + var res = GetTableReservation(table)!; +
+ @res.ReservationTime.ToString("HH:mm") +
+ } + @if (HasPendingOrder(table)) + { +
+ @FormatPrice(GetTableOrderTotal(table)) +
+ }
}
@@ -110,7 +125,7 @@ }
-@* ═══ RIGHT PANEL — CART + PAYMENT ═══ *@ +@* ═══ RIGHT PANEL — CART + SENT ITEMS + PAYMENT ═══ *@
@if (_paymentStep == PayStep.None) { @@ -119,11 +134,41 @@ {
@SelectedTable.Name - @_cartItems.Count món • @SelectedTable.Seats chỗ + @SelectedTable.Seats chỗ
+
+ @* ─── SENT ITEMS (already in kitchen) ─── *@ + @{ var sentForTable = GetSentItemsForTable(SelectedTable); } + @if (sentForTable.Any()) + { +
+
+ Đã gửi bếp +
+ @foreach (var item in sentForTable) + { +
+ @item.Name x@item.Qty + @FormatPrice(item.Price * item.Qty) +
+ } +
+ Tạm tính + @FormatPrice(sentForTable.Sum(i => i.Price * i.Qty)) +
+
+ } + + @* ─── CURRENT CART (not yet sent) ─── *@ @if (_cartItems.Any()) { + @if (sentForTable.Any()) + { +
+ Món mới +
+ } @foreach (var item in _cartItems) {
@@ -139,12 +184,12 @@
} } - else + else if (!sentForTable.Any()) {
@if (_viewMode == ViewMode.TableMap) { - Nhấn vào bàn rồi chọn Đặt món + Nhấn Đặt món để chọn món } else { @@ -153,10 +198,11 @@
}
+ } else @@ -188,7 +253,22 @@ Thanh toán — @SelectedTable?.Name - @FormatPrice(CartTotal) + @FormatPrice(GrandTotal) +
+
+
Chi tiết đơn
+ @{ var allItems = GetSentItemsForTable(SelectedTable!); } + @foreach (var item in allItems) + { +
+ @item.Name x@item.Qty + @FormatPrice(item.Price * item.Qty) +
+ } +
+ Tổng + @FormatPrice(GrandTotal) +
Tiền mặt - @FormatPrice(CartTotal) + @FormatPrice(GrandTotal)
Số tiền nhanh
@@ -254,7 +334,7 @@
-
@@ -271,7 +351,7 @@ @GetMethodLabel()
-
@FormatPrice(CartTotal)
+
@FormatPrice(GrandTotal)
@if (_selectedMethod == "qr") {
@@ -341,6 +421,9 @@ private IEnumerable FilteredTables => _activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection); + // ═══ RESERVATIONS ═══ + private List _reservations = new(); + // ═══ PRODUCTS & CART ═══ private List _products = new(); private string[] _menuCategories = { "Tất cả" }; @@ -350,6 +433,21 @@ _selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory); private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty); + // ═══ SENT ITEMS (per table — items sent to kitchen, not yet paid) ═══ + private readonly Dictionary> _tableOrders = new(); + private bool _sendingToKitchen; + private string? _kitchenMessage; + private bool _kitchenSuccess; + + private decimal GrandTotal + { + get + { + var sentTotal = SelectedTable != null ? GetSentItemsForTable(SelectedTable).Sum(i => i.Price * i.Qty) : 0; + return sentTotal + CartTotal; + } + } + // ═══ PAYMENT ═══ private enum PayStep { None, MethodSelect, AmountInput, Processing, Success } private PayStep _paymentStep = PayStep.None; @@ -359,8 +457,8 @@ private decimal _lastOrderTotal; private string _lastTransactionId = ""; private string _lastPaymentMethod = ""; - private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new(); - private decimal ChangeAmount => _receivedAmount - CartTotal; + private List _lastReceiptItems = new(); + private decimal ChangeAmount => _receivedAmount - GrandTotal; private bool _paymentProcessing; protected override async Task OnInitializedAsync() @@ -372,7 +470,8 @@ var tablesTask = DataService.GetTablesAsync(ShopId); var productsTask = DataService.GetProductsAsync(ShopId); var categoriesTask = DataService.GetCategoriesAsync(ShopId); - await Task.WhenAll(tablesTask, productsTask, categoriesTask); + var reservationsTask = DataService.GetReservationsAsync(ShopId, DateTime.Today.ToString("yyyy-MM-dd")); + await Task.WhenAll(tablesTask, productsTask, categoriesTask, reservationsTask); var apiTables = await tablesTask; _tables = apiTables.Select(t => new TableInfo( @@ -398,6 +497,8 @@ var productCats = _products.Select(p => p.Category).Distinct().ToList(); _menuCategories = new[] { "Tất cả" }.Concat(productCats).ToArray(); } + + _reservations = await reservationsTask; } catch { @@ -410,7 +511,11 @@ } // ═══ TABLE ACTIONS ═══ - private void SelectTable(TableInfo table) => SelectedTable = table; + private void SelectTable(TableInfo table) + { + SelectedTable = table; + _kitchenMessage = null; + } private void OpenMenu() { @@ -439,10 +544,87 @@ if (item.Qty <= 0) _cartItems.Remove(item); } + // ═══ SEND TO KITCHEN ═══ + private async Task SendToKitchen() + { + if (!_cartItems.Any() || SelectedTable == null || _sendingToKitchen) return; + _sendingToKitchen = true; + _kitchenMessage = null; + StateHasChanged(); + + try + { + var orderReq = new PosDataService.CreatePosOrderRequest( + ShopId, + null, + _cartItems.Select(i => new PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, i.Qty, i.Price, "PreparedFood")).ToList(), + null, null, null); + + var result = await DataService.CreatePosOrderAsync(orderReq); + + if (!_tableOrders.ContainsKey(SelectedTable.Id)) + _tableOrders[SelectedTable.Id] = new(); + + foreach (var item in _cartItems) + { + var existing = _tableOrders[SelectedTable.Id].FirstOrDefault(s => s.ProductId == item.ProductId); + if (existing != null) + existing.Qty += item.Qty; + else + _tableOrders[SelectedTable.Id].Add(new SentItem(item.ProductId, item.Name, item.Price, item.Qty)); + } + + _cartItems.Clear(); + _kitchenMessage = "Đã gửi bếp thành công!"; + _kitchenSuccess = true; + + // Auto-clear message after 3s + _ = Task.Delay(3000).ContinueWith(_ => InvokeAsync(() => { _kitchenMessage = null; StateHasChanged(); })); + } + catch + { + _kitchenMessage = "Không thể gửi bếp. Vui lòng thử lại."; + _kitchenSuccess = false; + } + finally + { + _sendingToKitchen = false; + } + } + + // ═══ TABLE ORDER HELPERS ═══ + private List GetSentItemsForTable(TableInfo table) => + _tableOrders.TryGetValue(table.Id, out var items) ? items : new(); + + private bool HasPendingOrder(TableInfo table) => + _tableOrders.ContainsKey(table.Id) && _tableOrders[table.Id].Any(); + + private decimal GetTableOrderTotal(TableInfo table) => + _tableOrders.TryGetValue(table.Id, out var items) ? items.Sum(i => i.Price * i.Qty) : 0; + + // ═══ RESERVATION HELPERS ═══ + private PosDataService.ReservationInfo? GetTableReservation(TableInfo table) + { + if (!Guid.TryParse(table.Id, out var tableGuid)) return null; + var now = DateTime.Now; + return _reservations.FirstOrDefault(r => + r.TableId == tableGuid && + r.Status is "confirmed" or "pending" && + Math.Abs((r.ReservationTime - now).TotalHours) < 2); + } + + private string GetEffectiveStatus(TableInfo table) + { + if (HasPendingOrder(table)) return "occupied"; + if (GetTableReservation(table) != null) return "reserved"; + return table.Status; + } + // ═══ PAYMENT FLOW ═══ private void StartPayment() { - if (!_cartItems.Any()) return; + if (SelectedTable == null || !HasPendingOrder(SelectedTable)) return; _paymentStep = PayStep.MethodSelect; } @@ -469,7 +651,7 @@ private List<(string Label, decimal Value)> GetQuickAmounts() { - var total = CartTotal; + var total = GrandTotal; var amounts = new List<(string, decimal)>(); var roundUp = Math.Ceiling(total / 50_000) * 50_000; if (roundUp == total) roundUp += 50_000; @@ -490,33 +672,20 @@ private async Task ConfirmPayment() { - if (_paymentProcessing) return; + if (_paymentProcessing || SelectedTable == null) return; _paymentProcessing = true; StateHasChanged(); - _lastOrderTotal = CartTotal; + _lastOrderTotal = GrandTotal; _lastPaymentMethod = _selectedMethod; - _lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList(); - - try - { - var orderReq = new PosDataService.CreatePosOrderRequest( - ShopId, - _selectedMethod, - _cartItems.Select(i => new PosDataService.PosOrderItemRequest( - i.ProductId, i.Name, i.Qty, i.Price)).ToList(), - null, null, null); - - var result = await DataService.CreatePosOrderAsync(orderReq); - _lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; - } - catch - { - _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; - } + _lastReceiptItems = GetSentItemsForTable(SelectedTable).ToList(); + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; _paymentProcessing = false; _paymentStep = PayStep.Success; + + // Clear table order + _tableOrders.Remove(SelectedTable.Id); StateHasChanged(); } @@ -556,7 +725,7 @@ private static string GetStatusColor(string status) => status switch { "available" => "rgba(34,197,94,.15)", "occupied" => "rgba(255,92,0,.18)", - "reserved" => "rgba(59,130,246,.18)", _ => "var(--pos-bg-interactive)" + "reserved" => "rgba(99,102,241,.18)", _ => "var(--pos-bg-interactive)" }; private static string GetStatusLabel(string status) => status switch @@ -574,4 +743,11 @@ public decimal Price { get; } = price; public int Qty { get; set; } = 1; } + private class SentItem(Guid productId, string name, decimal price, int qty) + { + public Guid ProductId { get; } = productId; + public string Name { get; } = name; + public decimal Price { get; } = price; + public int Qty { get; set; } = qty; + } }