diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopKitchen.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopKitchen.razor index 2b43267c..3db9cfc2 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopKitchen.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopKitchen.razor @@ -4,7 +4,7 @@
- @foreach (var st in new[] { ("all", "", "Tất cả"), ("pending", "clock", "Chờ"), ("preparing", "flame", "Đang làm"), ("completed", "check-circle", "Xong") }) + @foreach (var st in new[] { ("all", "", "Tất cả"), ("Pending", "clock", "Chờ"), ("InProgress", "flame", "Đang làm"), ("Ready", "check-circle", "Sẵn sàng"), ("Served", "check-check", "Đã phục vụ") }) {
- Chờ: @_kitchenTickets.Count(t => t.Status == "pending") - Đang làm: @_kitchenTickets.Count(t => t.Status == "preparing") - Xong: @_kitchenTickets.Count(t => t.Status == "completed") + Chờ: @_kitchenTickets.Count(t => t.Status == "Pending") + Đang làm: @_kitchenTickets.Count(t => t.Status == "InProgress") + Sẵn sàng: @_kitchenTickets.Count(t => t.Status == "Ready")
@if (!_kitchenTickets.Any()) @@ -27,8 +27,8 @@ else
@foreach (var ticket in _kitchenTickets) { - var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "preparing" => "#3B82F6", "completed" => "#22C55E", _ => "#6B6B6F" }; - var ticketLabel = ticket.Status switch { "pending" => "Chờ", "preparing" => "Đang làm", "completed" => "Hoàn thành", _ => ticket.Status }; + var ticketColor = ticket.Status switch { "Pending" => "#F59E0B", "InProgress" => "#3B82F6", "Ready" => "#22C55E", "Served" => "#6B6B6F", _ => "#6B6B6F" }; + var ticketLabel = ticket.Status switch { "Pending" => "Chờ", "InProgress" => "Đang làm", "Ready" => "Sẵn sàng", "Served" => "Đã phục vụ", _ => ticket.Status }; var elapsed = (DateTime.UtcNow - ticket.CreatedAt).TotalMinutes;
@@ -39,10 +39,26 @@ else
@(ticket.Station ?? "Bếp chính")
@((int)elapsed) phút - @if (ticket.Status != "completed") - { - - } +
+ @if (ticket.Status == "Pending") + { + + } + @if (ticket.Status == "InProgress") + { + + } + @if (ticket.Status == "Ready") + { + + } +
} @@ -70,19 +86,29 @@ else try { if (ShopId != Guid.Empty) - _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, status); + { + if (status == "all") + _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, ""); + else + _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, status); + } } catch (Exception ex) { _errorMessage = $"Không thể tải kitchen tickets: {ex.Message}"; } StateHasChanged(); } - private async Task MarkTicketDone(Guid ticketId) + private async Task UpdateTicketStatus(Guid ticketId, string newStatus) { try { - await DataService.UpdateTicketStatusAsync(ticketId, new PosDataService.UpdateTicketStatusRequest("completed")); + await DataService.UpdateTicketStatusAsync(ticketId, new PosDataService.UpdateTicketStatusRequest(newStatus)); if (ShopId != Guid.Empty) - _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, _kitchenStatusFilter); + { + if (_kitchenStatusFilter == "all") + _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, ""); + else + _kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, _kitchenStatusFilter); + } } catch (Exception ex) { _errorMessage = $"Không thể cập nhật trạng thái: {ex.Message}"; } StateHasChanged(); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor index 31ece1df..738edb73 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor @@ -10,9 +10,21 @@ Đang dùng: @_tables.Count(t => t.Status == "occupied") Đã đặt: @_tables.Count(t => t.Status == "reserved")
- +
+
+ + +
+ +
@if (_showTableForm) { @@ -36,8 +48,9 @@ { @RenderEmpty("grid-3x3", "#F59E0B", "Chưa có bàn nào", "Thêm bàn để quản lý sơ đồ phục vụ") } - else + else if (!_floorPlanView) { + @* ═══ GRID VIEW ═══ *@
@foreach (var table in _tables) { @@ -66,6 +79,63 @@ }
} + else + { + @* ═══ FLOOR PLAN VIEW ═══ *@ +
+ + @* Zone labels *@ + @{ + var zones = _tables.Where(t => t.PositionX.HasValue).GroupBy(t => t.Zone ?? "Chung").ToList(); + foreach (var zone in zones) + { + var firstTable = zone.First(); +
+ @zone.Key +
+ } + } + + @* Legend *@ +
+ Trống + Đang dùng + Đã đặt +
+ + @* Drag hint *@ +
+ Kéo bàn để sắp xếp +
+ + @foreach (var table in _tables) + { + var pos = GetPosition(table.Id); + var isDragging = _draggingTableId == table.Id; + var statusColor = table.Status switch { "available" => "#22C55E", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" }; + var bgColor = table.Status switch { "available" => "rgba(34,197,94,0.12)", "occupied" => "rgba(239,68,68,0.12)", "reserved" => "rgba(245,158,11,0.12)", _ => "rgba(107,107,111,0.12)" }; + +
+
@table.TableNumber
+
@table.Capacity chỗ
+
+
+ } +
+ } } else if (SubSection == "rooms") { @@ -188,6 +258,8 @@ else if (SubSection == "zones") // Tables state private List _tables = new(); + // View mode + private bool _floorPlanView; // Table form state private bool _showTableForm; private Guid? _editingTableId; @@ -208,12 +280,67 @@ else if (SubSection == "zones") private List AllZoneNames => _tables.Select(t => t.Zone ?? "Chung").Distinct() .Union(_customZones).Distinct().OrderBy(z => z).ToList(); + // ═══ FLOOR PLAN DRAG STATE ═══ + private Guid? _draggingTableId; + private double _dragStartClientX, _dragStartClientY; + private int _dragStartPosX, _dragStartPosY; + private readonly Dictionary _localPositions = new(); + protected override async Task OnInitializedAsync() { if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId); } + // ═══ FLOOR PLAN HELPERS ═══ + private (int X, int Y) GetPosition(Guid tableId) + { + if (_localPositions.TryGetValue(tableId, out var local)) + return local; + var table = _tables.FirstOrDefault(t => t.Id == tableId); + if (table?.PositionX != null && table.PositionY != null) + return (table.PositionX.Value, table.PositionY.Value); + // Auto-layout for tables without positions + var idx = _tables.FindIndex(t => t.Id == tableId); + var col = idx % 6; + var row = idx / 6; + return (40 + col * 120, 50 + row * 120); + } + + private void OnTablePointerDown(Microsoft.AspNetCore.Components.Web.PointerEventArgs e, PosDataService.TableInfo table) + { + _draggingTableId = table.Id; + var pos = GetPosition(table.Id); + _dragStartClientX = e.ClientX; + _dragStartClientY = e.ClientY; + _dragStartPosX = pos.X; + _dragStartPosY = pos.Y; + } + + private void OnCanvasPointerMove(Microsoft.AspNetCore.Components.Web.PointerEventArgs e) + { + if (_draggingTableId == null) return; + var newX = Math.Max(0, (int)(_dragStartPosX + (e.ClientX - _dragStartClientX))); + var newY = Math.Max(0, (int)(_dragStartPosY + (e.ClientY - _dragStartClientY))); + _localPositions[_draggingTableId.Value] = (newX, newY); + } + + private async Task OnCanvasPointerUp(Microsoft.AspNetCore.Components.Web.PointerEventArgs e) + { + if (_draggingTableId == null) return; + var tableId = _draggingTableId.Value; + _draggingTableId = null; + + if (_localPositions.TryGetValue(tableId, out var pos)) + { + try + { + await DataService.UpdateTablePositionAsync(tableId, pos.X, pos.Y); + } + catch { /* silently fail — position is still in local state */ } + } + } + // ═══ TABLE CRUD ═══ private void EditTable(PosDataService.TableInfo table) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor index bfe76182..e122d037 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor @@ -755,20 +755,23 @@ finally { _isLoading = false; } await LoadPaymentSettings(); + await RestoreCartFromLocalStorage(); } - private void AddToCart(Product product) + private async Task AddToCart(Product product) { if (_paymentStep != PayStep.None) return; var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id); if (existing != null) existing.Qty++; else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price)); + await SaveCartToLocalStorage(); } - private void ChangeQty(CartItem item, int delta) + private async Task ChangeQty(CartItem item, int delta) { item.Qty += delta; if (item.Qty <= 0) _cartItems.Remove(item); + await SaveCartToLocalStorage(); } // ═══════════════ VOUCHER ═══════════════ @@ -922,7 +925,7 @@ StateHasChanged(); } - private void ResetAfterPayment() + private async Task ResetAfterPayment() { _cartItems.Clear(); _paymentStep = PayStep.None; @@ -930,6 +933,7 @@ _receivedAmount = 0; _customAmountInput = ""; ClearVoucher(); + await SaveCartToLocalStorage(); } /// @@ -1282,6 +1286,43 @@ StateHasChanged(); } + // ═══════════════ LOCALSTORAGE PERSISTENCE ═══════════════ + private string CartStorageKey => $"pos_cafe_cart_{ShopId}"; + + private async Task SaveCartToLocalStorage() + { + try + { + var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList(); + await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey, + System.Text.Json.JsonSerializer.Serialize(cartData)); + } + catch { } + } + + private async Task RestoreCartFromLocalStorage() + { + try + { + var cartJson = await JS.InvokeAsync("localStorage.getItem", CartStorageKey); + if (!string.IsNullOrEmpty(cartJson)) + { + var items = System.Text.Json.JsonSerializer.Deserialize>(cartJson, _lsJsonOptions); + if (items != null) + foreach (var i in items) + _cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty }); + } + } + catch { } + } + + private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private record StoredCartItem(Guid ProductId, string Name, decimal Price, int Qty); + // ═══════════════ RECORDS ═══════════════ private record Product(Guid Id, string Name, decimal Price, string Category); private class CartItem(Guid productId, string name, decimal price) 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 19a75035..b4177062 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 @@ -499,6 +499,12 @@ } _reservations = await reservationsTask; + + // Load active table orders from DB (orders with table_id, status=Validated) + await LoadTableOrdersFromDb(); + + // Restore cart and selected table from localStorage + await RestoreStateFromLocalStorage(); } catch { @@ -511,10 +517,11 @@ } // ═══ TABLE ACTIONS ═══ - private void SelectTable(TableInfo table) + private async Task SelectTable(TableInfo table) { SelectedTable = table; _kitchenMessage = null; + await SaveStateToLocalStorage(); } private void OpenMenu() @@ -530,18 +537,20 @@ } // ═══ CART ═══ - private void AddToCart(Product product) + private async Task AddToCart(Product product) { if (_paymentStep != PayStep.None) return; var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id); if (existing != null) existing.Qty++; else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price)); + await SaveStateToLocalStorage(); } - private void ChangeQty(CartItem item, int delta) + private async Task ChangeQty(CartItem item, int delta) { item.Qty += delta; if (item.Qty <= 0) _cartItems.Remove(item); + await SaveStateToLocalStorage(); } // ═══ SEND TO KITCHEN ═══ @@ -554,30 +563,24 @@ try { + var tableGuid = Guid.TryParse(SelectedTable.Id, out var tg) ? tg : (Guid?)null; 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); + null, null, null, tableGuid); 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(); + + // Reload table orders from DB to reflect the new order + await LoadTableOrdersFromDb(); + _kitchenMessage = "Đã gửi bếp thành công!"; _kitchenSuccess = true; + await SaveStateToLocalStorage(); // Auto-clear message after 3s _ = Task.Delay(3000).ContinueWith(_ => InvokeAsync(() => { _kitchenMessage = null; StateHasChanged(); })); @@ -684,12 +687,13 @@ _paymentProcessing = false; _paymentStep = PayStep.Success; - // Clear table order - _tableOrders.Remove(SelectedTable.Id); + // Reload table orders from DB (the paid order will no longer be Validated) + await LoadTableOrdersFromDb(); + await SaveStateToLocalStorage(); StateHasChanged(); } - private void ResetAfterPayment() + private async Task ResetAfterPayment() { _cartItems.Clear(); _paymentStep = PayStep.None; @@ -698,6 +702,7 @@ _customAmountInput = ""; _viewMode = ViewMode.TableMap; SelectedTable = null; + await SaveStateToLocalStorage(); } private async Task PrintReceipt() @@ -733,6 +738,97 @@ "available" => "Trống", "occupied" => "Đang phục vụ", "reserved" => "Đã đặt", _ => status }; + // ═══ LOAD TABLE ORDERS FROM DB ═══ + private async Task LoadTableOrdersFromDb() + { + try + { + var activeOrders = await DataService.GetActiveTableOrdersAsync(ShopId); + _tableOrders.Clear(); + foreach (var order in activeOrders) + { + if (order.TableId == null) continue; + var tableKey = order.TableId.Value.ToString(); + if (!_tableOrders.ContainsKey(tableKey)) + _tableOrders[tableKey] = new(); + foreach (var item in order.Items) + { + var existing = _tableOrders[tableKey].FirstOrDefault(s => s.ProductId == item.ProductId); + if (existing != null) + existing.Qty += item.Quantity; + else + _tableOrders[tableKey].Add(new SentItem(item.ProductId, item.ProductName, item.UnitPrice, item.Quantity)); + } + } + } + catch { /* silently ignore — table orders will be empty */ } + } + + // ═══ LOCALSTORAGE PERSISTENCE (cart + selected table only) ═══ + private string CartStorageKey => $"pos_cart_{ShopId}"; + private string SelectedTableStorageKey => $"pos_selected_table_{ShopId}"; + + private async Task SaveStateToLocalStorage() + { + try + { + // Save cart items + var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList(); + await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey, + System.Text.Json.JsonSerializer.Serialize(cartData)); + + // Save selected table ID + if (SelectedTable != null) + await JS.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, SelectedTable.Id); + else + await JS.InvokeVoidAsync("localStorage.removeItem", SelectedTableStorageKey); + } + catch { /* localStorage may be unavailable */ } + } + + private async Task RestoreStateFromLocalStorage() + { + try + { + // Restore cart items + var cartJson = await JS.InvokeAsync("localStorage.getItem", CartStorageKey); + if (!string.IsNullOrEmpty(cartJson)) + { + var cartData = System.Text.Json.JsonSerializer.Deserialize>(cartJson, _lsJsonOptions); + if (cartData != null) + foreach (var i in cartData) + _cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty }); + } + + // Restore selected table + var savedTableId = await JS.InvokeAsync("localStorage.getItem", SelectedTableStorageKey); + if (!string.IsNullOrEmpty(savedTableId)) + { + SelectedTable = _tables.FirstOrDefault(t => t.Id == savedTableId); + if (SelectedTable != null && _cartItems.Any()) + _viewMode = ViewMode.Menu; + } + } + catch { /* silently ignore corrupt localStorage */ } + } + + private async Task ClearStateFromLocalStorage() + { + try + { + await JS.InvokeVoidAsync("localStorage.removeItem", CartStorageKey); + await JS.InvokeVoidAsync("localStorage.removeItem", SelectedTableStorageKey); + } + catch { } + } + + private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private record StoredItem(Guid ProductId, string Name, decimal Price, int Qty); + // ═══ MODELS ═══ private record TableInfo(string Id, string Name, int Seats, string Status, string Section); private record Product(Guid Id, string Name, decimal Price, string Category); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index b76e3664..00d9d711 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -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); + 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); 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, @@ -614,7 +614,7 @@ public class PosDataService // EN: POS order creation DTOs // VI: DTOs cho tạo đơn POS public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items, - decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null); + decimal? DiscountAmount = null, string? DiscountType = null, string? DiscountReference = null, Guid? TableId = null); public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, string? ProductType = "Physical"); public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status); @@ -627,6 +627,36 @@ public class PosDataService return null; } + // ═══ ACTIVE TABLE ORDERS ═══ + + // EN: DTOs for active table orders (orders with table_id, status=Validated) + // VI: DTOs cho active table orders (orders có table_id, status=Validated) + public record ActiveTableOrderDto + { + public Guid OrderId { get; init; } + public Guid? TableId { get; init; } + public decimal TotalAmount { get; init; } + public DateTime CreatedAt { get; init; } + public List Items { get; init; } = new(); + } + + public record ActiveTableOrderItemDto + { + public Guid ProductId { get; init; } + public string ProductName { get; init; } = string.Empty; + public int Quantity { get; init; } + public decimal UnitPrice { get; init; } + } + + public async Task> GetActiveTableOrdersAsync(Guid shopId) + { + AttachToken(); + var resp = await _http.GetAsync($"api/bff/orders/active-by-table?shopId={shopId}"); + if (resp.IsSuccessStatusCode) + return await resp.Content.ReadFromJsonAsync>(_jsonOptions) ?? new(); + return new(); + } + // ═══ CATEGORIES CRUD ═══ // EN: Category create/update request DTO @@ -758,6 +788,9 @@ public class PosDataService public async Task UpdateTableAsync(Guid tableId, CreateTableRequest req) { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _writeOptions); return r.IsSuccessStatusCode; } + public async Task UpdateTablePositionAsync(Guid tableId, int x, int y) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", new { positionX = x, positionY = y }, _writeOptions); return r.IsSuccessStatusCode; } + public async Task DeleteTableAsync(Guid tableId) { AttachToken(); var r = await _http.DeleteAsync($"api/bff/tables/{tableId}"); return r.IsSuccessStatusCode; } @@ -805,7 +838,7 @@ public class PosDataService public record KitchenTicketInfo(Guid Id, Guid SessionId, Guid OrderItemId, string ItemName, string? Station, int Priority, string Status, DateTime CreatedAt, DateTime? CompletedAt); public record UpdateTicketStatusRequest(string Status); - public async Task> GetKitchenTicketsAsync(Guid? shopId = null, string status = "pending") + public async Task> GetKitchenTicketsAsync(Guid? shopId = null, string status = "Pending") { if (!shopId.HasValue) return new(); var url = $"api/bff/shops/{shopId}/kitchen-tickets?status={status}"; @@ -813,7 +846,15 @@ public class PosDataService } public async Task UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req) - { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/kitchen/tickets/{ticketId}/status", req, _writeOptions); return r.IsSuccessStatusCode; } + { + AttachToken(); + var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/kitchen/tickets/{ticketId}/status") + { + Content = JsonContent.Create(req, options: _writeOptions) + }; + var r = await _http.SendAsync(request); + return r.IsSuccessStatusCode; + } // ═══ RECIPES CRUD ═══ diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs index 737adf65..d7843e25 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs @@ -56,16 +56,22 @@ public class FnbController : ControllerBase /// VI: Lấy phiếu bếp của shop — tùy chọn lọc theo trạng thái. /// [HttpGet("shops/{shopId}/kitchen-tickets")] - public Task GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending") => + public Task GetKitchenTickets(Guid shopId, [FromQuery] string status = "Pending") => _fnb.GetAsync($"/api/v1/kitchen/tickets?shopId={shopId}&status={Uri.EscapeDataString(status)}").ProxyAsync(); /// /// EN: Update kitchen ticket status. /// VI: Cập nhật trạng thái phiếu bếp. /// - [HttpPut("kitchen/tickets/{ticketId:guid}/status")] - public Task UpdateTicketStatus(Guid ticketId, [FromBody] JsonElement body) => - _fnb.PutAsJsonAsync($"/api/v1/kitchen/tickets/{ticketId}/status", body).ProxyAsync(); + [HttpPatch("kitchen/tickets/{ticketId:guid}/status")] + public Task UpdateTicketStatus(Guid ticketId, [FromBody] JsonElement body) + { + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/kitchen/tickets/{ticketId}/status") + { + Content = JsonContent.Create(body) + }; + return _fnb.SendAsync(request).ProxyAsync(); + } /// /// EN: Get recipes for a specific shop. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index f2575bbd..2ea4716c 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -218,6 +218,17 @@ public class OrderController : ControllerBase return await _order.PostAsJsonAsync("/api/v1/orders", enrichedBody).ProxyAsync(); } + /// + /// EN: Get active (unpaid) orders grouped by table for a shop. + /// VI: Lấy orders chưa thanh toán nhóm theo bàn cho shop. + /// + [HttpGet("orders/active-by-table")] + public Task GetActiveTableOrders([FromQuery] Guid? shopId = null) + { + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _order.GetAsync($"/api/v1/orders/active-by-table{qs}").ProxyAsync(); + } + /// /// EN: Get POS dashboard data — daily revenue, order count, popular items. /// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy. diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommand.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommand.cs index dab755bd..8226d178 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommand.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommand.cs @@ -12,5 +12,7 @@ namespace FnbEngine.API.Application.Commands; public record UpdateTableCommand( Guid TableId, int? Capacity = null, - string? Zone = null + string? Zone = null, + int? PositionX = null, + int? PositionY = null ) : IRequest; diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommandHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommandHandler.cs new file mode 100644 index 00000000..8c966def --- /dev/null +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Commands/UpdateTableCommandHandler.cs @@ -0,0 +1,43 @@ +// EN: Handler for UpdateTableCommand. +// VI: Handler cho UpdateTableCommand. + +using MediatR; +using FnbEngine.Domain.AggregatesModel.TableAggregate; + +namespace FnbEngine.API.Application.Commands; + +/// +/// EN: Handler for updating table details. +/// VI: Handler cập nhật thông tin bàn. +/// +public class UpdateTableCommandHandler : IRequestHandler +{ + private readonly ITableRepository _tableRepository; + private readonly ILogger _logger; + + public UpdateTableCommandHandler( + ITableRepository tableRepository, + ILogger logger) + { + _tableRepository = tableRepository ?? throw new ArgumentNullException(nameof(tableRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(UpdateTableCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Updating table {TableId}", request.TableId); + + var table = await _tableRepository.GetByIdAsync(request.TableId, cancellationToken); + if (table == null) + throw new InvalidOperationException($"Table not found: {request.TableId}"); + + if (request.PositionX.HasValue && request.PositionY.HasValue) + table.SetPosition(request.PositionX.Value, request.PositionY.Value); + + _tableRepository.Update(table); + await _tableRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Updated table {TableId}", request.TableId); + return true; + } +} diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs index d5f696cd..aafbb805 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs @@ -21,5 +21,7 @@ public record TableDto( string TableNumber, int Capacity, string? Zone, - string Status + string Status, + int? PositionX = null, + int? PositionY = null ); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs index 26d4e7ae..d8c7fc39 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs @@ -33,7 +33,9 @@ public class GetTablesQueryHandler : IRequestHandler { Success = true, Data = result }); } + /// + /// EN: Update table details. + /// VI: Cập nhật thông tin bàn. + /// + [HttpPut("{id}")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + public async Task>> UpdateTable( + Guid id, + [FromBody] UpdateTableRequest request, + CancellationToken ct = default) + { + var command = new UpdateTableCommand( + id, + request.Capacity, + request.Zone, + request.PositionX, + request.PositionY); + + var result = await _mediator.Send(command, ct); + return Ok(new ApiResponse { Success = true, Data = result }); + } + /// /// EN: Change table status. /// VI: Đổi trạng thái bàn. @@ -91,6 +114,16 @@ public record CreateTableRequest( int Capacity, string? Zone = null); +/// +/// EN: Request to update table details. +/// VI: Request cập nhật thông tin bàn. +/// +public record UpdateTableRequest( + int? Capacity = null, + string? Zone = null, + int? PositionX = null, + int? PositionY = null); + /// /// EN: Request to change table status. /// VI: Request đổi trạng thái bàn. diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs index 8a413805..0c421772 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs @@ -17,6 +17,8 @@ public class Table : Entity, IAggregateRoot private int _capacity; private string? _zone; private TableStatus _status = null!; + private int? _positionX; + private int? _positionY; private DateTime _createdAt; private DateTime? _updatedAt; @@ -26,6 +28,8 @@ public class Table : Entity, IAggregateRoot public string? Zone => _zone; public TableStatus Status => _status; public int StatusId { get; private set; } + public int? PositionX => _positionX; + public int? PositionY => _positionY; public DateTime CreatedAt => _createdAt; public DateTime? UpdatedAt => _updatedAt; @@ -75,4 +79,11 @@ public class Table : Entity, IAggregateRoot StatusId = TableStatus.Cleaning.Id; _updatedAt = DateTime.UtcNow; } + + public void SetPosition(int x, int y) + { + _positionX = x; + _positionY = y; + _updatedAt = DateTime.UtcNow; + } } diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs index 31049586..af8f62a7 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs @@ -45,6 +45,14 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("status_id") .IsRequired(); + builder.Property(t => t.PositionX) + .HasField("_positionX") + .HasColumnName("position_x"); + + builder.Property(t => t.PositionY) + .HasField("_positionY") + .HasColumnName("position_y"); + builder.Property(t => t.CreatedAt) .HasField("_createdAt") .HasColumnName("created_at") diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs index 013fa493..be8e9eec 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommand.cs @@ -15,7 +15,8 @@ public record CreateOrderCommand( List Items, decimal? DiscountAmount = null, string? DiscountType = null, - string? DiscountReference = null + string? DiscountReference = null, + Guid? TableId = null ) : IRequest; /// diff --git a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs index bc96433a..00d6dbb8 100644 --- a/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Commands/CreateOrderCommandHandler.cs @@ -37,7 +37,7 @@ public class CreateOrderCommandHandler : IRequestHandler>; + +public record ActiveTableOrderDto +{ + public Guid OrderId { get; init; } + public Guid? TableId { get; init; } + public decimal TotalAmount { get; init; } + public DateTime CreatedAt { get; init; } + public List Items { get; init; } = new(); +} + +public record ActiveTableOrderItemDto +{ + public Guid ProductId { get; init; } + public string ProductName { get; init; } = string.Empty; + public int Quantity { get; init; } + public decimal UnitPrice { get; init; } +} + +public class GetActiveTableOrdersQueryHandler : IRequestHandler> +{ + private readonly IDbConnection _connection; + + public GetActiveTableOrdersQueryHandler(IDbConnection connection) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + } + + public async Task> Handle( + GetActiveTableOrdersQuery request, + CancellationToken cancellationToken) + { + // EN: Get active orders (status = Validated=2) with table_id for this shop + // VI: Lấy orders đang active (status = Validated=2) có table_id cho shop này + var ordersSql = @" + SELECT o.id AS OrderId, o.table_id AS TableId, o.total_amount AS TotalAmount, o.created_at AS CreatedAt + FROM orders o + WHERE o.shop_id = @ShopId AND o.status_id = 2 AND o.table_id IS NOT NULL + ORDER BY o.created_at"; + + var orders = (await _connection.QueryAsync(ordersSql, new { request.ShopId })).ToList(); + if (!orders.Any()) return orders; + + // EN: Get items for all active orders in one query + // VI: Lấy items cho tất cả active orders trong 1 query + var orderIds = orders.Select(o => o.OrderId).ToArray(); + var itemsSql = @" + SELECT oi.order_id AS OrderId, oi.product_id AS ProductId, oi.product_name AS ProductName, + oi.quantity AS Quantity, oi.unit_price AS UnitPrice + FROM order_items oi + WHERE oi.order_id = ANY(@OrderIds)"; + + var items = await _connection.QueryAsync<(Guid OrderId, Guid ProductId, string ProductName, int Quantity, decimal UnitPrice)>( + itemsSql, new { OrderIds = orderIds }); + + var itemsByOrder = items.GroupBy(i => i.OrderId).ToDictionary(g => g.Key, g => g.ToList()); + + return orders.Select(o => o with + { + Items = itemsByOrder.TryGetValue(o.OrderId, out var orderItems) + ? orderItems.Select(i => new ActiveTableOrderItemDto + { + ProductId = i.ProductId, + ProductName = i.ProductName, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }).ToList() + : new() + }).ToList(); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/ListOrdersByShopQueryHandler.cs b/services/order-service-net/src/OrderService.API/Application/Queries/ListOrdersByShopQueryHandler.cs index 91743a68..90e4563b 100644 --- a/services/order-service-net/src/OrderService.API/Application/Queries/ListOrdersByShopQueryHandler.cs +++ b/services/order-service-net/src/OrderService.API/Application/Queries/ListOrdersByShopQueryHandler.cs @@ -44,6 +44,7 @@ public class ListOrdersByShopQueryHandler : IRequestHandler + /// EN: Get active (unpaid) orders grouped by table for a shop. + /// VI: Lấy orders chưa thanh toán nhóm theo bàn cho shop. + /// + [HttpGet("active-by-table")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetActiveTableOrders( + [FromQuery] Guid shopId, + CancellationToken cancellationToken = default) + { + var query = new GetActiveTableOrdersQuery(shopId); + var result = await _mediator.Send(query, cancellationToken); + return Ok(result); + } + /// /// EN: Get orders by customer. /// VI: Lấy orders theo khách hàng. diff --git a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs index c51769aa..bf44318c 100644 --- a/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/services/order-service-net/src/OrderService.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -15,6 +15,7 @@ public class Order : Entity, IAggregateRoot { private Guid _shopId; private Guid? _customerId; + private Guid? _tableId; private OrderStatus _status = null!; private decimal _totalAmount; private DateTime _createdAt; @@ -38,6 +39,12 @@ public class Order : Entity, IAggregateRoot /// public Guid? CustomerId => _customerId; + /// + /// EN: Table ID (optional — for dine-in restaurant orders). + /// VI: ID bàn (tùy chọn — cho đơn hàng dine-in nhà hàng). + /// + public Guid? TableId => _tableId; + /// /// EN: Order status. /// VI: Trạng thái đơn hàng. @@ -90,7 +97,7 @@ public class Order : Entity, IAggregateRoot /// EN: Create a new order. /// VI: Tạo đơn hàng mới. /// - public Order(Guid shopId, Guid? customerId = null) + public Order(Guid shopId, Guid? customerId = null, Guid? tableId = null) { if (shopId == Guid.Empty) throw new DomainException("Shop ID cannot be empty"); @@ -98,6 +105,7 @@ public class Order : Entity, IAggregateRoot Id = Guid.NewGuid(); _shopId = shopId; _customerId = customerId; + _tableId = tableId; _status = OrderStatus.Draft; StatusId = OrderStatus.Draft.Id; _totalAmount = 0; diff --git a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs index 84d62dd7..8bd9aabe 100644 --- a/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/services/order-service-net/src/OrderService.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -30,6 +30,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration builder.Property("_customerId") .HasColumnName("customer_id"); + builder.Property("_tableId") + .HasColumnName("table_id"); + builder.Property(o => o.StatusId) .HasColumnName("status_id") .IsRequired(); @@ -132,6 +135,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration // VI: Bỏ qua các properties được tính toán builder.Ignore(o => o.ShopId); builder.Ignore(o => o.CustomerId); + builder.Ignore(o => o.TableId); builder.Ignore(o => o.Status); builder.Ignore(o => o.TotalAmount); builder.Ignore(o => o.DiscountAmount);