feat(pos): implement table-based ordering, kitchen ticket workflow, and table floor plan management
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
|
||||
@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ụ") })
|
||||
{
|
||||
<button @onclick='() => LoadKitchenTickets(st.Item1)'
|
||||
style="padding:6px 14px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer;transition:all 0.2s;@(_kitchenStatusFilter == st.Item1 ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
|
||||
@@ -13,9 +13,9 @@
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Chờ: @_kitchenTickets.Count(t => t.Status == "pending")</span>
|
||||
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang làm: @_kitchenTickets.Count(t => t.Status == "preparing")</span>
|
||||
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Xong: @_kitchenTickets.Count(t => t.Status == "completed")</span>
|
||||
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Chờ: @_kitchenTickets.Count(t => t.Status == "Pending")</span>
|
||||
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang làm: @_kitchenTickets.Count(t => t.Status == "InProgress")</span>
|
||||
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Sẵn sàng: @_kitchenTickets.Count(t => t.Status == "Ready")</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (!_kitchenTickets.Any())
|
||||
@@ -27,8 +27,8 @@ else
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
||||
@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;
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-top:4px solid @ticketColor;border-radius:12px;padding:16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
@@ -39,10 +39,26 @@ else
|
||||
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:12px;">@(ticket.Station ?? "Bếp chính")</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--admin-border-subtle);">
|
||||
<span style="font-size:11px;color:var(--admin-text-tertiary);"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @((int)elapsed) phút</span>
|
||||
@if (ticket.Status != "completed")
|
||||
{
|
||||
<button @onclick='() => MarkTicketDone(ticket.Id)' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(34,197,94,0.15);color:#22C55E;font-size:11px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:3px;"><i data-lucide="check" style="width:11px;height:11px;"></i> Xong</button>
|
||||
}
|
||||
<div style="display:flex;gap:4px;">
|
||||
@if (ticket.Status == "Pending")
|
||||
{
|
||||
<button @onclick='() => UpdateTicketStatus(ticket.Id, "inprogress")' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(59,130,246,0.15);color:#3B82F6;font-size:11px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:3px;">
|
||||
<i data-lucide="flame" style="width:11px;height:11px;"></i> Bắt đầu
|
||||
</button>
|
||||
}
|
||||
@if (ticket.Status == "InProgress")
|
||||
{
|
||||
<button @onclick='() => UpdateTicketStatus(ticket.Id, "ready")' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(34,197,94,0.15);color:#22C55E;font-size:11px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:3px;">
|
||||
<i data-lucide="check" style="width:11px;height:11px;"></i> Xong
|
||||
</button>
|
||||
}
|
||||
@if (ticket.Status == "Ready")
|
||||
{
|
||||
<button @onclick='() => UpdateTicketStatus(ticket.Id, "served")' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(107,107,111,0.15);color:#6B6B6F;font-size:11px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:3px;">
|
||||
<i data-lucide="check-check" style="width:11px;height:11px;"></i> Đã phục vụ
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -10,9 +10,21 @@
|
||||
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang dùng: @_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 = 4; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
|
||||
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm bàn
|
||||
</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div style="display:flex;background:var(--admin-bg-elevated);border-radius:8px;padding:2px;">
|
||||
<button @onclick='() => _floorPlanView = false'
|
||||
style="padding:5px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:pointer;@(!_floorPlanView ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
|
||||
<i data-lucide="grid-3x3" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Lưới
|
||||
</button>
|
||||
<button @onclick='() => _floorPlanView = true'
|
||||
style="padding:5px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:pointer;@(_floorPlanView ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
|
||||
<i data-lucide="map" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Sơ đồ
|
||||
</button>
|
||||
</div>
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 4; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
|
||||
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm bàn
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@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 ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
|
||||
@foreach (var table in _tables)
|
||||
{
|
||||
@@ -66,6 +79,63 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ═══ FLOOR PLAN VIEW ═══ *@
|
||||
<div style="position:relative;width:100%;height:600px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-radius:14px;overflow:hidden;
|
||||
background-image:radial-gradient(circle,rgba(255,255,255,.06) 1px,transparent 1px);background-size:24px 24px;"
|
||||
@onpointermove="OnCanvasPointerMove" @onpointerup="OnCanvasPointerUp" @onpointerleave="OnCanvasPointerUp"
|
||||
@onpointermove:preventDefault @onpointerup:preventDefault>
|
||||
|
||||
@* 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();
|
||||
<div style="position:absolute;left:@(GetPosition(firstTable.Id).X)px;top:@(Math.Max(0, GetPosition(firstTable.Id).Y - 28))px;
|
||||
font-size:10px;font-weight:700;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:1px;pointer-events:none;">
|
||||
@zone.Key
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* Legend *@
|
||||
<div style="position:absolute;bottom:12px;right:12px;display:flex;gap:12px;font-size:10px;color:var(--admin-text-tertiary);background:rgba(0,0,0,.4);padding:6px 12px;border-radius:8px;">
|
||||
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#22C55E;margin-right:3px;"></span>Trống</span>
|
||||
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#EF4444;margin-right:3px;"></span>Đang dùng</span>
|
||||
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#F59E0B;margin-right:3px;"></span>Đã đặt</span>
|
||||
</div>
|
||||
|
||||
@* Drag hint *@
|
||||
<div style="position:absolute;top:12px;left:12px;font-size:10px;color:var(--admin-text-tertiary);background:rgba(0,0,0,.4);padding:4px 10px;border-radius:6px;">
|
||||
<i data-lucide="move" style="width:10px;height:10px;vertical-align:middle;margin-right:3px;"></i>Kéo bàn để sắp xếp
|
||||
</div>
|
||||
|
||||
@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)" };
|
||||
|
||||
<div @onpointerdown="(e) => 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");">
|
||||
<div style="font-size:15px;font-weight:700;color:var(--admin-text-primary);">@table.TableNumber</div>
|
||||
<div style="font-size:10px;color:var(--admin-text-tertiary);margin-top:2px;">@table.Capacity chỗ</div>
|
||||
<div style="width:6px;height:6px;border-radius:50%;background:@statusColor;margin:4px auto 0;"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (SubSection == "rooms")
|
||||
{
|
||||
@@ -188,6 +258,8 @@ else if (SubSection == "zones")
|
||||
|
||||
// Tables state
|
||||
private List<PosDataService.TableInfo> _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<string> 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<Guid, (int X, int Y)> _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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<string?>("localStorage.getItem", CartStorageKey);
|
||||
if (!string.IsNullOrEmpty(cartJson))
|
||||
{
|
||||
var items = System.Text.Json.JsonSerializer.Deserialize<List<StoredCartItem>>(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)
|
||||
|
||||
@@ -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<string?>("localStorage.getItem", CartStorageKey);
|
||||
if (!string.IsNullOrEmpty(cartJson))
|
||||
{
|
||||
var cartData = System.Text.Json.JsonSerializer.Deserialize<List<StoredItem>>(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<string?>("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);
|
||||
|
||||
@@ -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<PosOrderItemRequest> 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<ActiveTableOrderItemDto> 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<List<ActiveTableOrderDto>> 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<List<ActiveTableOrderDto>>(_jsonOptions) ?? new();
|
||||
return new();
|
||||
}
|
||||
|
||||
// ═══ CATEGORIES CRUD ═══
|
||||
|
||||
// EN: Category create/update request DTO
|
||||
@@ -758,6 +788,9 @@ public class PosDataService
|
||||
public async Task<bool> UpdateTableAsync(Guid tableId, CreateTableRequest req)
|
||||
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _writeOptions); return r.IsSuccessStatusCode; }
|
||||
|
||||
public async Task<bool> 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<bool> 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<List<KitchenTicketInfo>> GetKitchenTicketsAsync(Guid? shopId = null, string status = "pending")
|
||||
public async Task<List<KitchenTicketInfo>> 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<bool> 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 ═══
|
||||
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId}/kitchen-tickets")]
|
||||
public Task<IActionResult> GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending") =>
|
||||
public Task<IActionResult> GetKitchenTickets(Guid shopId, [FromQuery] string status = "Pending") =>
|
||||
_fnb.GetAsync($"/api/v1/kitchen/tickets?shopId={shopId}&status={Uri.EscapeDataString(status)}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update kitchen ticket status.
|
||||
/// VI: Cập nhật trạng thái phiếu bếp.
|
||||
/// </summary>
|
||||
[HttpPut("kitchen/tickets/{ticketId:guid}/status")]
|
||||
public Task<IActionResult> UpdateTicketStatus(Guid ticketId, [FromBody] JsonElement body) =>
|
||||
_fnb.PutAsJsonAsync($"/api/v1/kitchen/tickets/{ticketId}/status", body).ProxyAsync();
|
||||
[HttpPatch("kitchen/tickets/{ticketId:guid}/status")]
|
||||
public Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get recipes for a specific shop.
|
||||
|
||||
@@ -218,6 +218,17 @@ public class OrderController : ControllerBase
|
||||
return await _order.PostAsJsonAsync("/api/v1/orders", enrichedBody).ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("orders/active-by-table")]
|
||||
public Task<IActionResult> GetActiveTableOrders([FromQuery] Guid? shopId = null)
|
||||
{
|
||||
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
|
||||
return _order.GetAsync($"/api/v1/orders/active-by-table{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -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<bool>;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// EN: Handler for UpdateTableCommand.
|
||||
// VI: Handler cho UpdateTableCommand.
|
||||
|
||||
using MediatR;
|
||||
using FnbEngine.Domain.AggregatesModel.TableAggregate;
|
||||
|
||||
namespace FnbEngine.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for updating table details.
|
||||
/// VI: Handler cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
public class UpdateTableCommandHandler : IRequestHandler<UpdateTableCommand, bool>
|
||||
{
|
||||
private readonly ITableRepository _tableRepository;
|
||||
private readonly ILogger<UpdateTableCommandHandler> _logger;
|
||||
|
||||
public UpdateTableCommandHandler(
|
||||
ITableRepository tableRepository,
|
||||
ILogger<UpdateTableCommandHandler> logger)
|
||||
{
|
||||
_tableRepository = tableRepository ?? throw new ArgumentNullException(nameof(tableRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,7 @@ public record TableDto(
|
||||
string TableNumber,
|
||||
int Capacity,
|
||||
string? Zone,
|
||||
string Status
|
||||
string Status,
|
||||
int? PositionX = null,
|
||||
int? PositionY = null
|
||||
);
|
||||
|
||||
@@ -33,7 +33,9 @@ public class GetTablesQueryHandler : IRequestHandler<GetTablesQuery, IEnumerable
|
||||
t.TableNumber,
|
||||
t.Capacity,
|
||||
t.Zone,
|
||||
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available"
|
||||
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available",
|
||||
t.PositionX,
|
||||
t.PositionY
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,29 @@ public class TablesController : ControllerBase
|
||||
new ApiResponse<CreateTableResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update table details.
|
||||
/// VI: Cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<ApiResponse<bool>>> 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<bool> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change table status.
|
||||
/// VI: Đổi trạng thái bàn.
|
||||
@@ -91,6 +114,16 @@ public record CreateTableRequest(
|
||||
int Capacity,
|
||||
string? Zone = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to update table details.
|
||||
/// VI: Request cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
public record UpdateTableRequest(
|
||||
int? Capacity = null,
|
||||
string? Zone = null,
|
||||
int? PositionX = null,
|
||||
int? PositionY = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to change table status.
|
||||
/// VI: Request đổi trạng thái bàn.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration<Table>
|
||||
.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")
|
||||
|
||||
@@ -15,7 +15,8 @@ public record CreateOrderCommand(
|
||||
List<OrderItemRequest> Items,
|
||||
decimal? DiscountAmount = null,
|
||||
string? DiscountType = null,
|
||||
string? DiscountReference = null
|
||||
string? DiscountReference = null,
|
||||
Guid? TableId = null
|
||||
) : IRequest<CreateOrderResult>;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -37,7 +37,7 @@ public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Cre
|
||||
|
||||
// EN: Create order aggregate
|
||||
// VI: Tạo order aggregate
|
||||
var order = new Order(request.ShopId, request.CustomerId);
|
||||
var order = new Order(request.ShopId, request.CustomerId, request.TableId);
|
||||
|
||||
// EN: Add items to order
|
||||
// VI: Thêm items vào order
|
||||
|
||||
@@ -42,6 +42,7 @@ public record OrderSummaryDto
|
||||
public Guid Id { get; init; }
|
||||
public Guid ShopId { get; init; }
|
||||
public Guid? CustomerId { get; init; }
|
||||
public Guid? TableId { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public decimal TotalAmount { get; init; }
|
||||
public long ItemCount { get; init; }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// EN: Query to get active (unpaid) orders for a shop, grouped by table — includes items.
|
||||
// VI: Query lấy orders chưa thanh toán của shop, nhóm theo bàn — bao gồm items.
|
||||
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using MediatR;
|
||||
using OrderService.API.Application.DTOs;
|
||||
|
||||
namespace OrderService.API.Application.Queries;
|
||||
|
||||
public record GetActiveTableOrdersQuery(Guid ShopId) : IRequest<List<ActiveTableOrderDto>>;
|
||||
|
||||
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<ActiveTableOrderItemDto> 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<GetActiveTableOrdersQuery, List<ActiveTableOrderDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetActiveTableOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
}
|
||||
|
||||
public async Task<List<ActiveTableOrderDto>> 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<ActiveTableOrderDto>(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();
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public class ListOrdersByShopQueryHandler : IRequestHandler<ListOrdersByShopQuer
|
||||
o.id AS Id,
|
||||
o.shop_id AS ShopId,
|
||||
o.customer_id AS CustomerId,
|
||||
o.table_id AS TableId,
|
||||
os.name AS Status,
|
||||
o.total_amount AS TotalAmount,
|
||||
o.created_at AS CreatedAt,
|
||||
|
||||
@@ -194,6 +194,21 @@ public class OrdersController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("active-by-table")]
|
||||
[ProducesResponseType(typeof(List<ActiveTableOrderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<ActiveTableOrderDto>>> GetActiveTableOrders(
|
||||
[FromQuery] Guid shopId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new GetActiveTableOrdersQuery(shopId);
|
||||
var result = await _mediator.Send(query, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get orders by customer.
|
||||
/// VI: Lấy orders theo khách hàng.
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public Guid? CustomerId => _customerId;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public Guid? TableId => _tableId;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -30,6 +30,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
builder.Property<Guid?>("_customerId")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
builder.Property<Guid?>("_tableId")
|
||||
.HasColumnName("table_id");
|
||||
|
||||
builder.Property(o => o.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
@@ -132,6 +135,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user