feat(tpos-client): implement send to kitchen workflow, table reservations, and enhanced admin zone management.
This commit is contained in:
@@ -642,6 +642,10 @@
|
||||
|
||||
// ═══ FINANCE ═══
|
||||
case "finance":
|
||||
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
|
||||
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
|
||||
Đơn hàng theo cửa hàng · Ví tiền chung cho tài khoản
|
||||
</div>
|
||||
var finOrders = _financePeriod switch {
|
||||
"7d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-7)).ToList(),
|
||||
"30d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30)).ToList(),
|
||||
@@ -885,6 +889,10 @@
|
||||
|
||||
// ═══ CUSTOMERS + MEMBERSHIP LEVELS ═══
|
||||
case "customers":
|
||||
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
|
||||
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
|
||||
Dữ liệu khách hàng chung cho tất cả cửa hàng trong thương hiệu
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;border-bottom:2px solid var(--admin-border-subtle);padding-bottom:8px;">
|
||||
@foreach (var (tab, label) in new[] { ("members","Khách hàng"), ("levels","Cấp bậc"), ("exp","Điểm EXP") })
|
||||
{
|
||||
@@ -1206,7 +1214,7 @@
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số bàn *</label><input type="text" @bind="_newTableNumber" placeholder="VD: 01, A1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newTableCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khu vực</label><input type="text" @bind="_newTableZone" placeholder="VD: Sảnh, VIP" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khu vực</label><select @bind="_newTableZone" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);"><option value="">— Chưa chọn —</option>@foreach (var z in AllZoneNames) { <option value="@z">@z</option> }</select></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="admin-btn-primary" @onclick="@(_editingTableId.HasValue ? SaveTable : AddTable)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingTableId.HasValue ? "Cập nhật" : "Lưu")</button>
|
||||
@@ -1896,6 +1904,10 @@
|
||||
|
||||
// ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══
|
||||
case "promotions":
|
||||
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
|
||||
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
|
||||
Chiến dịch khuyến mãi chung cho tất cả cửa hàng trong thương hiệu
|
||||
</div>
|
||||
@* ─── Sub-tabs: Campaigns | Vouchers ─── *@
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;">
|
||||
@{ var promoTabs = new[] { ("campaigns", "Chiến dịch", "tag"), ("vouchers", "Mã voucher", "ticket") }; }
|
||||
@@ -2387,7 +2399,9 @@
|
||||
|
||||
// ═══ R4: ZONES / KHU VỰC (Nhà hàng) ═══
|
||||
case "zones":
|
||||
var zoneGroups = _tables.GroupBy(t => t.Zone ?? "Chung").Select((g, i) => new { Name = g.Key, Count = g.Count(), Color = _zoneColors[i % _zoneColors.Length], Icon = _zoneIcons[i % _zoneIcons.Length] }).OrderBy(z => z.Name).ToList();
|
||||
var tableZones = _tables.GroupBy(t => t.Zone ?? "Chung").Select(g => new { Name = g.Key, Count = g.Count() }).ToList();
|
||||
var allZones = tableZones.Select(z => z.Name).Union(_customZones).Distinct().OrderBy(z => z).ToList();
|
||||
var zoneGroups = allZones.Select((z, i) => new { Name = z, Count = tableZones.FirstOrDefault(tz => tz.Name == z)?.Count ?? 0, Color = _zoneColors[i % _zoneColors.Length], Icon = _zoneIcons[i % _zoneIcons.Length] }).ToList();
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 class="admin-panel__title"><i data-lucide="map-pin" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>Quản lý khu vực</h3>
|
||||
@@ -2408,9 +2422,9 @@
|
||||
@if (_zoneFormMessage != null) { <div style="margin-top:6px;font-size:12px;color:@(_zoneFormSuccess ? "#22C55E" : "#EF4444");">@_zoneFormMessage</div> }
|
||||
</div>
|
||||
}
|
||||
@if (!_tables.Any())
|
||||
@if (!zoneGroups.Any())
|
||||
{
|
||||
@RenderEmpty("map-pin", "#F59E0B", "Chưa có khu vực nào", "Thêm bàn với khu vực trong phần Quản lý Bàn trước", "grid-3x3", "Quản lý Bàn", $"/admin/shop/{ShopId}/tables")
|
||||
@RenderEmpty("map-pin", "#F59E0B", "Chưa có khu vực nào", "Nhấn 'Thêm khu vực' ở trên để tạo khu vực đầu tiên", "", "", "")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2800,8 +2814,11 @@
|
||||
private string? _editingZoneOriginalName;
|
||||
private string? _zoneFormMessage;
|
||||
private bool _zoneFormSuccess;
|
||||
private readonly List<string> _customZones = new();
|
||||
private static readonly string[] _zoneColors = { "#3B82F6", "#A855F7", "#22C55E", "#F59E0B", "#EC4899", "#6366F1" };
|
||||
private static readonly string[] _zoneIcons = { "building", "crown", "trees", "wine", "coffee", "map-pin" };
|
||||
private List<string> AllZoneNames => _tables.Select(t => t.Zone ?? "Chung").Distinct()
|
||||
.Union(_customZones).Distinct().OrderBy(z => z).ToList();
|
||||
// Kitchen state
|
||||
private List<PosDataService.KitchenTicketInfo> _kitchenTickets = new();
|
||||
private string _kitchenStatusFilter = "all";
|
||||
@@ -3632,10 +3649,13 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add: the zone name will be available when creating tables
|
||||
_zoneFormMessage = $"Khu vực '{_newZoneName.Trim()}' đã sẵn sàng. Hãy thêm bàn với khu vực này trong phần Quản lý Bàn."; _zoneFormSuccess = true;
|
||||
_newTableZone = _newZoneName.Trim();
|
||||
var zoneName = _newZoneName.Trim();
|
||||
if (!_customZones.Contains(zoneName) && !_tables.Any(t => (t.Zone ?? "Chung") == zoneName))
|
||||
_customZones.Add(zoneName);
|
||||
_zoneFormMessage = $"Đã thêm khu vực '{zoneName}'."; _zoneFormSuccess = true;
|
||||
_newTableZone = zoneName;
|
||||
_newZoneName = "";
|
||||
_showZoneForm = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@*
|
||||
EN: Restaurant POS Desktop — Table map grid + order panel with full payment flow.
|
||||
VI: POS Nhà hàng Desktop — Lưới sơ đồ bàn + panel đặt món với luồng thanh toán đầy đủ.
|
||||
EN: Restaurant POS Desktop — Table map + dine-in order flow (order → kitchen → add more → pay).
|
||||
VI: POS Nhà hàng Desktop — Sơ đồ bàn + luồng dine-in (gọi món → bếp → gọi thêm → thanh toán).
|
||||
*@
|
||||
@page "/pos/{ShopId:guid}/restaurant"
|
||||
@layout PosLayout
|
||||
@@ -49,15 +49,30 @@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px;padding:8px 0;">
|
||||
@foreach (var table in FilteredTables)
|
||||
{
|
||||
var effectiveStatus = GetEffectiveStatus(table);
|
||||
var hasReservation = GetTableReservation(table) is not null;
|
||||
<div @onclick="() => SelectTable(table)"
|
||||
style="background:@GetStatusColor(table.Status);border-radius:var(--pos-radius);
|
||||
style="background:@GetStatusColor(effectiveStatus);border-radius:var(--pos-radius);
|
||||
padding:16px;text-align:center;cursor:pointer;border:2px solid @(SelectedTable?.Id == table.Id ? "var(--pos-orange-primary)" : "transparent");
|
||||
transition:all .2s ease;">
|
||||
transition:all .2s ease;position:relative;">
|
||||
<div style="font-size:20px;font-weight:700;">@table.Name</div>
|
||||
<div style="font-size:12px;color:rgba(255,255,255,.7);margin-top:4px;">@table.Seats chỗ</div>
|
||||
<div style="font-size:11px;margin-top:6px;font-weight:600;text-transform:uppercase;">
|
||||
@GetStatusLabel(table.Status)
|
||||
@GetStatusLabel(effectiveStatus)
|
||||
</div>
|
||||
@if (hasReservation)
|
||||
{
|
||||
var res = GetTableReservation(table)!;
|
||||
<div style="position:absolute;top:4px;right:4px;background:rgba(99,102,241,.9);color:#fff;font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600;">
|
||||
@res.ReservationTime.ToString("HH:mm")
|
||||
</div>
|
||||
}
|
||||
@if (HasPendingOrder(table))
|
||||
{
|
||||
<div style="position:absolute;top:4px;left:4px;background:rgba(255,92,0,.9);color:#fff;font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600;">
|
||||
@FormatPrice(GetTableOrderTotal(table))
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -110,7 +125,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ RIGHT PANEL — CART + PAYMENT ═══ *@
|
||||
@* ═══ RIGHT PANEL — CART + SENT ITEMS + PAYMENT ═══ *@
|
||||
<div class="pos-cart-panel">
|
||||
@if (_paymentStep == PayStep.None)
|
||||
{
|
||||
@@ -119,11 +134,41 @@
|
||||
{
|
||||
<div class="pos-cart-header">
|
||||
<span class="pos-cart-header__title">@SelectedTable.Name</span>
|
||||
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_cartItems.Count món • @SelectedTable.Seats chỗ</span>
|
||||
<span style="font-size:12px;color:var(--pos-text-tertiary);">@SelectedTable.Seats chỗ</span>
|
||||
</div>
|
||||
|
||||
<div class="pos-cart-items">
|
||||
@* ─── SENT ITEMS (already in kitchen) ─── *@
|
||||
@{ var sentForTable = GetSentItemsForTable(SelectedTable); }
|
||||
@if (sentForTable.Any())
|
||||
{
|
||||
<div style="padding:8px 12px;background:rgba(255,92,0,.08);border-radius:8px;margin-bottom:8px;">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--pos-orange-primary);margin-bottom:6px;display:flex;align-items:center;gap:4px;">
|
||||
<i data-lucide="chef-hat" style="width:12px;height:12px;"></i> Đã gửi bếp
|
||||
</div>
|
||||
@foreach (var item in sentForTable)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;padding:3px 0;font-size:12px;color:var(--pos-text-secondary);">
|
||||
<span>@item.Name x@item.Qty</span>
|
||||
<span>@FormatPrice(item.Price * item.Qty)</span>
|
||||
</div>
|
||||
}
|
||||
<div style="display:flex;justify-content:space-between;padding-top:6px;border-top:1px dashed var(--pos-border-subtle);font-size:12px;font-weight:600;color:var(--pos-orange-primary);">
|
||||
<span>Tạm tính</span>
|
||||
<span>@FormatPrice(sentForTable.Sum(i => i.Price * i.Qty))</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ─── CURRENT CART (not yet sent) ─── *@
|
||||
@if (_cartItems.Any())
|
||||
{
|
||||
@if (sentForTable.Any())
|
||||
{
|
||||
<div style="font-size:11px;font-weight:700;color:var(--pos-text-tertiary);margin-bottom:4px;padding:0 4px;display:flex;align-items:center;gap:4px;">
|
||||
<i data-lucide="plus-circle" style="width:12px;height:12px;"></i> Món mới
|
||||
</div>
|
||||
}
|
||||
@foreach (var item in _cartItems)
|
||||
{
|
||||
<div class="pos-cart-item">
|
||||
@@ -139,12 +184,12 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (!sentForTable.Any())
|
||||
{
|
||||
<div style="text-align:center;padding:32px 16px;color:var(--pos-text-tertiary);font-size:13px;">
|
||||
@if (_viewMode == ViewMode.TableMap)
|
||||
{
|
||||
<span>Nhấn vào bàn rồi chọn <b>Đặt món</b></span>
|
||||
<span>Nhấn <b>Đặt món</b> để chọn món</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -153,10 +198,11 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pos-cart-footer">
|
||||
<div class="pos-cart-total">
|
||||
<span class="pos-cart-total__label">Tổng cộng</span>
|
||||
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
|
||||
<span class="pos-cart-total__value">@FormatPrice(GrandTotal)</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
@if (_viewMode == ViewMode.TableMap)
|
||||
@@ -166,10 +212,29 @@
|
||||
<i data-lucide="utensils" style="width:16px;height:16px;display:inline;vertical-align:middle;margin-right:4px;"></i>Đặt món
|
||||
</button>
|
||||
}
|
||||
<button class="pos-btn-checkout" style="flex:1;" @onclick="StartPayment" disabled="@(!_cartItems.Any())">
|
||||
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> Thanh toán
|
||||
</button>
|
||||
@if (_cartItems.Any())
|
||||
{
|
||||
<button style="flex:1;padding:12px;border-radius:var(--pos-radius);background:rgba(34,197,94,.15);border:1px solid rgba(34,197,94,.3);color:var(--pos-success);cursor:pointer;font-size:13px;font-weight:600;"
|
||||
@onclick="SendToKitchen" disabled="@_sendingToKitchen">
|
||||
<i data-lucide="chef-hat" style="width:16px;height:16px;display:inline;vertical-align:middle;margin-right:4px;"></i>
|
||||
@(_sendingToKitchen ? "Đang gửi..." : "Gửi bếp")
|
||||
</button>
|
||||
}
|
||||
@if (sentForTable.Any() && !_cartItems.Any())
|
||||
{
|
||||
<button class="pos-btn-checkout" style="flex:1;" @onclick="StartPayment">
|
||||
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> Thanh toán
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_kitchenMessage))
|
||||
{
|
||||
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;font-size:12px;
|
||||
background:@(_kitchenSuccess ? "rgba(34,197,94,.12)" : "rgba(239,68,68,.12)");
|
||||
color:@(_kitchenSuccess ? "var(--pos-success)" : "var(--pos-danger)");">
|
||||
@_kitchenMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -188,7 +253,22 @@
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;">Thanh toán — @SelectedTable?.Name</span>
|
||||
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
|
||||
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(GrandTotal)</span>
|
||||
</div>
|
||||
<div style="padding:12px 16px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--pos-text-tertiary);margin-bottom:8px;">Chi tiết đơn</div>
|
||||
@{ var allItems = GetSentItemsForTable(SelectedTable!); }
|
||||
@foreach (var item in allItems)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:13px;">
|
||||
<span>@item.Name x@item.Qty</span>
|
||||
<span style="font-weight:600;">@FormatPrice(item.Price * item.Qty)</span>
|
||||
</div>
|
||||
}
|
||||
<div style="display:flex;justify-content:space-between;padding-top:8px;border-top:1px solid var(--pos-border-subtle);font-weight:700;font-size:14px;margin-top:8px;">
|
||||
<span>Tổng</span>
|
||||
<span style="color:var(--pos-orange-primary);">@FormatPrice(GrandTotal)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pos-payment-methods">
|
||||
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("cash")'>
|
||||
@@ -219,7 +299,7 @@
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;display:flex;align-items:center;gap:6px;"><i data-lucide="wallet" style="width:16px;height:16px;"></i> Tiền mặt</span>
|
||||
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</span>
|
||||
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(GrandTotal)</span>
|
||||
</div>
|
||||
<div class="pos-payment-amount-section">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Số tiền nhanh</div>
|
||||
@@ -254,7 +334,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
|
||||
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < CartTotal)">
|
||||
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < GrandTotal)">
|
||||
Xác nhận thanh toán
|
||||
</button>
|
||||
</div>
|
||||
@@ -271,7 +351,7 @@
|
||||
<span style="font-weight:600;">@GetMethodLabel()</span>
|
||||
</div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;">
|
||||
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(CartTotal)</div>
|
||||
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(GrandTotal)</div>
|
||||
@if (_selectedMethod == "qr")
|
||||
{
|
||||
<div style="width:180px;height:180px;background:white;border-radius:12px;display:flex;align-items:center;justify-content:center;">
|
||||
@@ -341,6 +421,9 @@
|
||||
private IEnumerable<TableInfo> FilteredTables =>
|
||||
_activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection);
|
||||
|
||||
// ═══ RESERVATIONS ═══
|
||||
private List<PosDataService.ReservationInfo> _reservations = new();
|
||||
|
||||
// ═══ PRODUCTS & CART ═══
|
||||
private List<Product> _products = new();
|
||||
private string[] _menuCategories = { "Tất cả" };
|
||||
@@ -350,6 +433,21 @@
|
||||
_selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
|
||||
private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
|
||||
|
||||
// ═══ SENT ITEMS (per table — items sent to kitchen, not yet paid) ═══
|
||||
private readonly Dictionary<string, List<SentItem>> _tableOrders = new();
|
||||
private bool _sendingToKitchen;
|
||||
private string? _kitchenMessage;
|
||||
private bool _kitchenSuccess;
|
||||
|
||||
private decimal GrandTotal
|
||||
{
|
||||
get
|
||||
{
|
||||
var sentTotal = SelectedTable != null ? GetSentItemsForTable(SelectedTable).Sum(i => i.Price * i.Qty) : 0;
|
||||
return sentTotal + CartTotal;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ PAYMENT ═══
|
||||
private enum PayStep { None, MethodSelect, AmountInput, Processing, Success }
|
||||
private PayStep _paymentStep = PayStep.None;
|
||||
@@ -359,8 +457,8 @@
|
||||
private decimal _lastOrderTotal;
|
||||
private string _lastTransactionId = "";
|
||||
private string _lastPaymentMethod = "";
|
||||
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
|
||||
private decimal ChangeAmount => _receivedAmount - CartTotal;
|
||||
private List<SentItem> _lastReceiptItems = new();
|
||||
private decimal ChangeAmount => _receivedAmount - GrandTotal;
|
||||
private bool _paymentProcessing;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -372,7 +470,8 @@
|
||||
var tablesTask = DataService.GetTablesAsync(ShopId);
|
||||
var productsTask = DataService.GetProductsAsync(ShopId);
|
||||
var categoriesTask = DataService.GetCategoriesAsync(ShopId);
|
||||
await Task.WhenAll(tablesTask, productsTask, categoriesTask);
|
||||
var reservationsTask = DataService.GetReservationsAsync(ShopId, DateTime.Today.ToString("yyyy-MM-dd"));
|
||||
await Task.WhenAll(tablesTask, productsTask, categoriesTask, reservationsTask);
|
||||
|
||||
var apiTables = await tablesTask;
|
||||
_tables = apiTables.Select(t => new TableInfo(
|
||||
@@ -398,6 +497,8 @@
|
||||
var productCats = _products.Select(p => p.Category).Distinct().ToList();
|
||||
_menuCategories = new[] { "Tất cả" }.Concat(productCats).ToArray();
|
||||
}
|
||||
|
||||
_reservations = await reservationsTask;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -410,7 +511,11 @@
|
||||
}
|
||||
|
||||
// ═══ TABLE ACTIONS ═══
|
||||
private void SelectTable(TableInfo table) => SelectedTable = table;
|
||||
private void SelectTable(TableInfo table)
|
||||
{
|
||||
SelectedTable = table;
|
||||
_kitchenMessage = null;
|
||||
}
|
||||
|
||||
private void OpenMenu()
|
||||
{
|
||||
@@ -439,10 +544,87 @@
|
||||
if (item.Qty <= 0) _cartItems.Remove(item);
|
||||
}
|
||||
|
||||
// ═══ SEND TO KITCHEN ═══
|
||||
private async Task SendToKitchen()
|
||||
{
|
||||
if (!_cartItems.Any() || SelectedTable == null || _sendingToKitchen) return;
|
||||
_sendingToKitchen = true;
|
||||
_kitchenMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var orderReq = new PosDataService.CreatePosOrderRequest(
|
||||
ShopId,
|
||||
null,
|
||||
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
|
||||
i.ProductId, i.Name, i.Qty, i.Price, "PreparedFood")).ToList(),
|
||||
null, null, null);
|
||||
|
||||
var result = await DataService.CreatePosOrderAsync(orderReq);
|
||||
|
||||
if (!_tableOrders.ContainsKey(SelectedTable.Id))
|
||||
_tableOrders[SelectedTable.Id] = new();
|
||||
|
||||
foreach (var item in _cartItems)
|
||||
{
|
||||
var existing = _tableOrders[SelectedTable.Id].FirstOrDefault(s => s.ProductId == item.ProductId);
|
||||
if (existing != null)
|
||||
existing.Qty += item.Qty;
|
||||
else
|
||||
_tableOrders[SelectedTable.Id].Add(new SentItem(item.ProductId, item.Name, item.Price, item.Qty));
|
||||
}
|
||||
|
||||
_cartItems.Clear();
|
||||
_kitchenMessage = "Đã gửi bếp thành công!";
|
||||
_kitchenSuccess = true;
|
||||
|
||||
// Auto-clear message after 3s
|
||||
_ = Task.Delay(3000).ContinueWith(_ => InvokeAsync(() => { _kitchenMessage = null; StateHasChanged(); }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_kitchenMessage = "Không thể gửi bếp. Vui lòng thử lại.";
|
||||
_kitchenSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendingToKitchen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ TABLE ORDER HELPERS ═══
|
||||
private List<SentItem> GetSentItemsForTable(TableInfo table) =>
|
||||
_tableOrders.TryGetValue(table.Id, out var items) ? items : new();
|
||||
|
||||
private bool HasPendingOrder(TableInfo table) =>
|
||||
_tableOrders.ContainsKey(table.Id) && _tableOrders[table.Id].Any();
|
||||
|
||||
private decimal GetTableOrderTotal(TableInfo table) =>
|
||||
_tableOrders.TryGetValue(table.Id, out var items) ? items.Sum(i => i.Price * i.Qty) : 0;
|
||||
|
||||
// ═══ RESERVATION HELPERS ═══
|
||||
private PosDataService.ReservationInfo? GetTableReservation(TableInfo table)
|
||||
{
|
||||
if (!Guid.TryParse(table.Id, out var tableGuid)) return null;
|
||||
var now = DateTime.Now;
|
||||
return _reservations.FirstOrDefault(r =>
|
||||
r.TableId == tableGuid &&
|
||||
r.Status is "confirmed" or "pending" &&
|
||||
Math.Abs((r.ReservationTime - now).TotalHours) < 2);
|
||||
}
|
||||
|
||||
private string GetEffectiveStatus(TableInfo table)
|
||||
{
|
||||
if (HasPendingOrder(table)) return "occupied";
|
||||
if (GetTableReservation(table) != null) return "reserved";
|
||||
return table.Status;
|
||||
}
|
||||
|
||||
// ═══ PAYMENT FLOW ═══
|
||||
private void StartPayment()
|
||||
{
|
||||
if (!_cartItems.Any()) return;
|
||||
if (SelectedTable == null || !HasPendingOrder(SelectedTable)) return;
|
||||
_paymentStep = PayStep.MethodSelect;
|
||||
}
|
||||
|
||||
@@ -469,7 +651,7 @@
|
||||
|
||||
private List<(string Label, decimal Value)> GetQuickAmounts()
|
||||
{
|
||||
var total = CartTotal;
|
||||
var total = GrandTotal;
|
||||
var amounts = new List<(string, decimal)>();
|
||||
var roundUp = Math.Ceiling(total / 50_000) * 50_000;
|
||||
if (roundUp == total) roundUp += 50_000;
|
||||
@@ -490,33 +672,20 @@
|
||||
|
||||
private async Task ConfirmPayment()
|
||||
{
|
||||
if (_paymentProcessing) return;
|
||||
if (_paymentProcessing || SelectedTable == null) return;
|
||||
_paymentProcessing = true;
|
||||
StateHasChanged();
|
||||
|
||||
_lastOrderTotal = CartTotal;
|
||||
_lastOrderTotal = GrandTotal;
|
||||
_lastPaymentMethod = _selectedMethod;
|
||||
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var orderReq = new PosDataService.CreatePosOrderRequest(
|
||||
ShopId,
|
||||
_selectedMethod,
|
||||
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
|
||||
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
|
||||
null, null, null);
|
||||
|
||||
var result = await DataService.CreatePosOrderAsync(orderReq);
|
||||
_lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
|
||||
}
|
||||
_lastReceiptItems = GetSentItemsForTable(SelectedTable).ToList();
|
||||
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
|
||||
|
||||
_paymentProcessing = false;
|
||||
_paymentStep = PayStep.Success;
|
||||
|
||||
// Clear table order
|
||||
_tableOrders.Remove(SelectedTable.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -556,7 +725,7 @@
|
||||
private static string GetStatusColor(string status) => status switch
|
||||
{
|
||||
"available" => "rgba(34,197,94,.15)", "occupied" => "rgba(255,92,0,.18)",
|
||||
"reserved" => "rgba(59,130,246,.18)", _ => "var(--pos-bg-interactive)"
|
||||
"reserved" => "rgba(99,102,241,.18)", _ => "var(--pos-bg-interactive)"
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string status) => status switch
|
||||
@@ -574,4 +743,11 @@
|
||||
public decimal Price { get; } = price;
|
||||
public int Qty { get; set; } = 1;
|
||||
}
|
||||
private class SentItem(Guid productId, string name, decimal price, int qty)
|
||||
{
|
||||
public Guid ProductId { get; } = productId;
|
||||
public string Name { get; } = name;
|
||||
public decimal Price { get; } = price;
|
||||
public int Qty { get; set; } = qty;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user