feat(tpos-client): implement send to kitchen workflow, table reservations, and enhanced admin zone management.

This commit is contained in:
Ho Ngoc Hai
2026-03-05 06:00:21 +07:00
parent a4f4c4755e
commit 926d4ee83c
2 changed files with 247 additions and 51 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}