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)" };
+
+
OnTablePointerDown(e, table)"
+ @onpointerdown:preventDefault
+ style="position:absolute;left:@(pos.X)px;top:@(pos.Y)px;
+ width:90px;text-align:center;cursor:grab;user-select:none;touch-action:none;
+ background:@bgColor;border:2px solid @(isDragging ? "var(--admin-orange-primary)" : statusColor);
+ border-radius:@(table.Capacity <= 2 ? "50%" : table.Capacity <= 4 ? "12px" : "16px");
+ padding:@(table.Capacity <= 2 ? "18px 8px" : "12px 8px");
+ transition:@(isDragging ? "none" : "box-shadow .2s");
+ box-shadow:@(isDragging ? "0 8px 24px rgba(0,0,0,.3)" : "0 2px 8px rgba(0,0,0,.15)");
+ z-index:@(isDragging ? "100" : "1");">
+
@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);