feat(fnb, tpos): implement table QR code scanning for customer menu and reservation management

This commit is contained in:
Ho Ngoc Hai
2026-03-05 08:28:32 +07:00
parent cfcdbd069d
commit cd979970e7
14 changed files with 790 additions and 5 deletions

View File

@@ -0,0 +1,10 @@
@inherits LayoutComponentBase
<MudThemeProvider IsDarkMode="false" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<div style="min-height:100vh;background:#F9FAFB;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
@Body
</div>

View File

@@ -304,7 +304,7 @@
break;
case "reservations":
@RenderStubSection("calendar-check", "#3B82F6", "Đặt bàn", "Quản lý đặt bàn trước — tính năng đang phát triển.")
<ShopReservations ShopId="@(_shopGuid ?? Guid.Empty)" />
break;
case "happy-hour":

View File

@@ -0,0 +1,262 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:12px;">
<input type="date" @bind="_selectedDate" @bind:event="onchange" @bind:after="LoadReservations"
style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" />
<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ờ"), ("confirmed", "check-circle", "Xác nhận"), ("seated", "armchair", "Đã ngồi"), ("cancelled", "x-circle", "Đã hủy"), ("no_show", "user-x", "Vắng") })
{
var count = st.Item1 == "all" ? _reservations.Count : _reservations.Count(r => r.Status == st.Item1);
<button @onclick='() => { _statusFilter = st.Item1; }'
style="padding:6px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:pointer;transition:all 0.2s;display:flex;align-items:center;gap:4px;@(_statusFilter == st.Item1 ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
@if (!string.IsNullOrEmpty(st.Item2)) { <i data-lucide="@st.Item2" style="width:12px;height:12px;"></i> }@st.Item3 @if (count > 0) { <span style="font-size:10px;opacity:0.8;">(@count)</span> }
</button>
}
</div>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="ToggleForm">
<i data-lucide="@(_showForm ? "x" : "plus-circle")" style="width:16px;height:16px;"></i>@(_showForm ? "Đóng" : "Tạo đặt bàn")
</button>
</div>
@if (_showForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(59,130,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title"><i data-lucide="calendar-plus" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>Tạo đặt bàn mới</h3></div>
<div class="admin-panel__body">
<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;">Tên khách *</label><input type="text" @bind="_formGuestName" placeholder="Nhập tên khách" 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ố điện thoại</label><input type="tel" @bind="_formPhone" placeholder="0912 345 678" 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ố khách *</label><input type="number" @bind="_formPartySize" 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>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian đặt *</label><input type="datetime-local" @bind="_formReservationTime" 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;">Bàn</label>
<select @bind="_formTableId" 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 t in _tables.Where(t => t.Status == "available"))
{
<option value="@t.Id">@t.TableNumber (@t.Capacity chỗ) @(t.Zone != null ? $"- {t.Zone}" : "")</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ghi chú</label><input type="text" @bind="_formNote" placeholder="VD: Sinh nhật, yêu cầu đặc biệt" 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>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="CreateReservation" disabled="@_saving" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="@(_saving ? "loader" : "check")" style="width:14px;height:14px;"></i>@(_saving ? "Đang lưu..." : "Tạo đặt bàn")
</button>
<button @onclick='() => _showForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;">
<i data-lucide="x" style="width:14px;height:14px;"></i>Hủy
</button>
</div>
@if (_formMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_formSuccess ? "#22C55E" : "#EF4444");">@_formMessage</div> }
</div>
</div>
}
@if (_loading)
{
<div style="text-align:center;padding:40px;color:var(--admin-text-tertiary);">
<i data-lucide="loader" style="width:24px;height:24px;animation:spin 1s linear infinite;"></i>
<p style="margin-top:8px;">Đang tải...</p>
</div>
}
else if (!FilteredReservations.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="calendar-check" style="width:36px;height:36px;color:#3B82F6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--admin-text-primary);">Chưa có đặt bàn</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">@(_statusFilter == "all" ? "Nhấn 'Tạo đặt bàn' để thêm lịch đặt bàn mới" : "Không có đặt bàn nào ở trạng thái này")</p>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px;">
@foreach (var res in FilteredReservations)
{
var (statusColor, statusBg, statusText, statusIcon) = res.Status switch
{
"pending" => ("#F59E0B", "rgba(245,158,11,0.08)", "Chờ xác nhận", "clock"),
"confirmed" => ("#3B82F6", "rgba(59,130,246,0.08)", "Đã xác nhận", "check-circle"),
"seated" => ("#22C55E", "rgba(34,197,94,0.08)", "Đã ngồi", "armchair"),
"cancelled" => ("#EF4444", "rgba(239,68,68,0.08)", "Đã hủy", "x-circle"),
"no_show" => ("#6B6B6F", "rgba(107,107,111,0.08)", "Vắng mặt", "user-x"),
_ => ("#6B6B6F", "rgba(107,107,111,0.08)", res.Status, "circle")
};
var tableName = _tables.FirstOrDefault(t => t.Id == res.TableId)?.TableNumber;
<div style="background:@statusBg;border:1px solid @($"{statusColor}33");border-radius:14px;padding:20px;position:relative;">
@* Status badge *@
<div style="position:absolute;top:12px;right:12px;display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;padding:3px 10px;border-radius:6px;background:@($"{statusColor}15");">
<i data-lucide="@statusIcon" style="width:12px;height:12px;"></i>@statusText
</div>
@* Guest info *@
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:44px;height:44px;border-radius:12px;background:@($"{statusColor}18");display:flex;align-items:center;justify-content:center;">
<i data-lucide="user" style="width:20px;height:20px;color:@statusColor;"></i>
</div>
<div>
<div style="font-size:16px;font-weight:700;">@res.GuestName</div>
@if (!string.IsNullOrEmpty(res.Phone))
{
<div style="font-size:12px;color:var(--admin-text-tertiary);display:flex;align-items:center;gap:4px;">
<i data-lucide="phone" style="width:10px;height:10px;"></i>@res.Phone
</div>
}
</div>
</div>
@* Details *@
<div style="display:flex;gap:16px;margin-bottom:12px;font-size:13px;color:var(--admin-text-secondary);">
<span style="display:flex;align-items:center;gap:4px;"><i data-lucide="clock" style="width:14px;height:14px;"></i>@res.ReservationTime.ToString("HH:mm")</span>
<span style="display:flex;align-items:center;gap:4px;"><i data-lucide="users" style="width:14px;height:14px;"></i>@res.PartySize khách</span>
@if (tableName != null)
{
<span style="display:flex;align-items:center;gap:4px;"><i data-lucide="grid-3x3" style="width:14px;height:14px;"></i>Bàn @tableName</span>
}
</div>
@if (!string.IsNullOrEmpty(res.Note))
{
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:12px;padding:8px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06);">
<i data-lucide="message-square" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>@res.Note
</div>
}
@* Actions *@
@if (res.Status == "pending" || res.Status == "confirmed")
{
<div style="display:flex;gap:8px;padding-top:12px;border-top:1px solid @($"{statusColor}22");">
@if (res.Status == "pending")
{
<button @onclick='() => UpdateStatus(res.Id, "confirmed")' style="flex:1;padding:7px 0;border-radius:8px;border:none;font-size:12px;font-weight:600;cursor:pointer;background:rgba(59,130,246,0.15);color:#3B82F6;">
<i data-lucide="check" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Xác nhận
</button>
}
@if (res.Status == "confirmed")
{
<button @onclick='() => UpdateStatus(res.Id, "seated")' style="flex:1;padding:7px 0;border-radius:8px;border:none;font-size:12px;font-weight:600;cursor:pointer;background:rgba(34,197,94,0.15);color:#22C55E;">
<i data-lucide="armchair" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Đã ngồi
</button>
<button @onclick='() => UpdateStatus(res.Id, "no_show")' style="flex:1;padding:7px 0;border-radius:8px;border:none;font-size:12px;font-weight:600;cursor:pointer;background:rgba(107,107,111,0.15);color:#6B6B6F;">
<i data-lucide="user-x" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Vắng
</button>
}
<button @onclick='() => UpdateStatus(res.Id, "cancelled")' style="padding:7px 14px;border-radius:8px;border:none;font-size:12px;font-weight:600;cursor:pointer;background:rgba(239,68,68,0.15);color:#EF4444;">
<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;"></i>
</button>
</div>
}
</div>
}
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.ReservationInfo> _reservations = new();
private List<PosDataService.TableInfo> _tables = new();
private string _statusFilter = "all";
private DateTime _selectedDate = DateTime.Today;
private bool _loading = true;
private bool _showForm;
private bool _saving;
// Form fields
private string _formGuestName = "";
private string? _formPhone;
private int _formPartySize = 2;
private DateTime _formReservationTime = DateTime.Today.AddHours(12);
private string _formTableId = "";
private string? _formNote;
private string? _formMessage;
private bool _formSuccess;
private IEnumerable<PosDataService.ReservationInfo> FilteredReservations =>
_statusFilter == "all" ? _reservations : _reservations.Where(r => r.Status == _statusFilter);
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
{
_tables = await DataService.GetTablesAsync(ShopId);
await LoadReservations();
}
_loading = false;
}
private async Task LoadReservations()
{
if (ShopId == Guid.Empty) return;
_reservations = await DataService.GetReservationsAsync(ShopId, _selectedDate.ToString("yyyy-MM-dd"));
}
private void ToggleForm()
{
_showForm = !_showForm;
if (_showForm)
{
_formGuestName = "";
_formPhone = null;
_formPartySize = 2;
_formReservationTime = _selectedDate.Date.AddHours(DateTime.Now.Hour + 1);
_formTableId = "";
_formNote = null;
_formMessage = null;
}
}
private async Task CreateReservation()
{
_formMessage = null;
if (string.IsNullOrWhiteSpace(_formGuestName))
{
_formMessage = "Vui lòng nhập tên khách."; _formSuccess = false; return;
}
if (_formPartySize <= 0)
{
_formMessage = "Số khách phải lớn hơn 0."; _formSuccess = false; return;
}
_saving = true;
try
{
Guid? tableId = Guid.TryParse(_formTableId, out var tid) ? tid : null;
var req = new PosDataService.CreateReservationRequest2(
ShopId, _formGuestName.Trim(), _formPartySize, _formReservationTime,
string.IsNullOrWhiteSpace(_formPhone) ? null : _formPhone.Trim(),
tableId,
string.IsNullOrWhiteSpace(_formNote) ? null : _formNote.Trim());
var ok = await DataService.CreateReservationAsync(req);
if (ok)
{
_formMessage = "Tạo đặt bàn thành công!"; _formSuccess = true;
_formGuestName = ""; _formPhone = null; _formPartySize = 2; _formTableId = ""; _formNote = null;
await LoadReservations();
}
else
{
_formMessage = "Không thể tạo đặt bàn. Vui lòng thử lại."; _formSuccess = false;
}
}
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
finally { _saving = false; }
}
private async Task UpdateStatus(Guid reservationId, string newStatus)
{
try
{
var ok = await DataService.UpdateReservationStatusAsync(reservationId, newStatus);
if (ok) await LoadReservations();
}
catch (Exception ex) { Console.Error.WriteLine($"Update status failed: {ex.Message}"); }
}
}

View File

@@ -1,6 +1,8 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@inject IJSRuntime JS
@inject NavigationManager Nav
@if (SubSection == "tables")
{
@@ -60,6 +62,7 @@
var statusText = table.Status switch { "available" => "Trống", "occupied" => "Đang dùng", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => table.Status };
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;text-align:center;position:relative;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick='() => ShowQrModal(table)' style="background:rgba(139,92,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Mã QR"><i data-lucide="qr-code" style="color:#8B5CF6;width:12px;height:12px;"></i></button>
<button @onclick='() => EditTable(table)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:12px;height:12px;"></i></button>
<button @onclick='() => DeleteTableItem(table.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
</div>
@@ -252,6 +255,58 @@ else if (SubSection == "zones")
</div>
}
@* ═══ QR MODAL ═══ *@
@if (_showQrModal && _qrTable != null)
{
<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:1000;display:flex;align-items:center;justify-content:center;" @onclick="() => _showQrModal = false">
<div style="background:var(--admin-bg-elevated);border-radius:20px;padding:32px;width:400px;max-width:90vw;text-align:center;box-shadow:0 24px 48px rgba(0,0,0,0.3);" @onclick:stopPropagation>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h3 style="margin:0;font-size:18px;font-weight:700;">QR Code — Bàn @_qrTable.TableNumber</h3>
<button @onclick="() => _showQrModal = false" style="background:none;border:none;cursor:pointer;color:var(--admin-text-tertiary);"><i data-lucide="x" style="width:20px;height:20px;"></i></button>
</div>
@if (_qrGenerating)
{
<div style="padding:40px;color:var(--admin-text-tertiary);">
<i data-lucide="loader" style="width:24px;height:24px;animation:spin 1s linear infinite;"></i>
<p style="margin-top:8px;">Đang tạo mã QR...</p>
</div>
}
else if (!string.IsNullOrEmpty(_qrToken))
{
var qrUrl = $"{Nav.BaseUri}table/{_qrToken}";
<div id="qr-code-container" style="background:white;border-radius:16px;padding:24px;margin-bottom:16px;display:inline-block;">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=@(Uri.EscapeDataString(qrUrl))" alt="QR Code" style="width:200px;height:200px;" />
</div>
<div style="margin-bottom:16px;">
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;">URL khách hàng:</div>
<div style="font-size:13px;padding:8px 12px;border-radius:8px;background:var(--admin-bg-main);border:1px solid var(--admin-border-subtle);word-break:break-all;color:var(--admin-text-primary);">@qrUrl</div>
</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button @onclick='() => CopyQrUrl(qrUrl)' style="padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">
<i data-lucide="copy" style="width:14px;height:14px;"></i>Sao chép URL
</button>
<button @onclick='() => PrintQr(qrUrl)' style="padding:8px 16px;border-radius:8px;border:none;background:var(--admin-orange-primary);color:white;cursor:pointer;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">
<i data-lucide="printer" style="width:14px;height:14px;"></i>In QR
</button>
</div>
@if (_qrCopied)
{
<div style="margin-top:8px;font-size:12px;color:#22C55E;">Đã sao chép!</div>
}
}
else
{
<div style="padding:20px;">
<p style="color:var(--admin-text-tertiary);margin-bottom:16px;">Bàn chưa có mã QR. Tạo mã QR để khách hàng quét đặt món.</p>
<button class="admin-btn-primary" @onclick="GenerateQr" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="qr-code" style="width:16px;height:16px;"></i>Tạo mã QR
</button>
</div>
}
</div>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public string SubSection { get; set; } = "tables";
@@ -260,6 +315,12 @@ else if (SubSection == "zones")
private List<PosDataService.TableInfo> _tables = new();
// View mode
private bool _floorPlanView;
// QR modal state
private bool _showQrModal;
private PosDataService.TableInfo? _qrTable;
private string? _qrToken;
private bool _qrGenerating;
private bool _qrCopied;
// Table form state
private bool _showTableForm;
private Guid? _editingTableId;
@@ -430,6 +491,53 @@ else if (SubSection == "zones")
}
}
// ═══ QR METHODS ═══
private void ShowQrModal(PosDataService.TableInfo table)
{
_qrTable = table;
_qrToken = table.QrToken;
_qrGenerating = false;
_qrCopied = false;
_showQrModal = true;
}
private async Task GenerateQr()
{
if (_qrTable == null) return;
_qrGenerating = true;
try
{
var token = await DataService.GenerateTableQrTokenAsync(_qrTable.Id);
if (token != null)
{
_qrToken = token;
_tables = await DataService.GetTablesAsync(ShopId);
}
}
catch (Exception ex) { Console.Error.WriteLine($"QR generation failed: {ex.Message}"); }
finally { _qrGenerating = false; }
}
private async Task CopyQrUrl(string url)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", url);
_qrCopied = true;
StateHasChanged();
}
catch { }
}
private async Task PrintQr(string url)
{
try
{
await JS.InvokeVoidAsync("eval", $"var w=window.open('','_blank','width=400,height=500');w.document.write('<html><body style=\"text-align:center;font-family:sans-serif;padding:40px\"><h2>Bàn {_qrTable?.TableNumber}</h2><img src=\"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data='+encodeURIComponent('{url}')+'\" /><p style=\"font-size:12px;color:#666;margin-top:16px\">{url}</p></body></html>');w.document.close();w.print();");
}
catch { }
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">

View File

@@ -0,0 +1,280 @@
@page "/table/{Token}"
@layout WebClientTpos.Client.Layout.CustomerLayout
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject NavigationManager Nav
@if (_loading)
{
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;">
<div style="text-align:center;color:#6B7280;">
<div style="width:48px;height:48px;border:3px solid #E5E7EB;border-top-color:#F59E0B;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<p>Đang tải menu...</p>
</div>
</div>
}
else if (_error != null)
{
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;">
<div style="text-align:center;padding:40px;">
<div style="width:64px;height:64px;border-radius:50%;background:#FEF2F2;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<span style="font-size:28px;">!</span>
</div>
<h2 style="font-size:20px;font-weight:700;color:#1F2937;margin:0 0 8px;">Không tìm thấy bàn</h2>
<p style="color:#6B7280;font-size:14px;">@_error</p>
</div>
</div>
}
else
{
@* ═══ HEADER ═══ *@
<div style="background:white;box-shadow:0 1px 3px rgba(0,0,0,0.1);position:sticky;top:0;z-index:50;">
<div style="max-width:640px;margin:0 auto;padding:16px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<h1 style="font-size:18px;font-weight:700;color:#1F2937;margin:0;">@_shopName</h1>
<p style="font-size:13px;color:#6B7280;margin:4px 0 0;">Bàn @_tableNumber</p>
</div>
<div style="display:flex;gap:8px;">
@if (_cart.Any())
{
<button @onclick='() => _showCart = !_showCart' style="position:relative;padding:8px 16px;border-radius:10px;border:none;background:#F59E0B;color:white;font-weight:600;font-size:14px;cursor:pointer;">
Giỏ hàng (@_cart.Sum(c => c.Qty))
</button>
}
<button @onclick="CallStaff" style="padding:8px 16px;border-radius:10px;border:2px solid #F59E0B;background:transparent;color:#F59E0B;font-weight:600;font-size:14px;cursor:pointer;">
@(_staffCalled ? "Đã gọi!" : "Gọi NV")
</button>
</div>
</div>
</div>
</div>
<div style="max-width:640px;margin:0 auto;padding:20px;">
@* ═══ CATEGORY TABS ═══ *@
@if (_categories.Any())
{
<div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:12px;margin-bottom:16px;-webkit-overflow-scrolling:touch;">
<button @onclick='() => _selectedCategory = null'
style="white-space:nowrap;padding:8px 16px;border-radius:20px;border:none;font-size:13px;font-weight:600;cursor:pointer;@(_selectedCategory == null ? "background:#F59E0B;color:white;" : "background:#F3F4F6;color:#6B7280;")">
Tất cả
</button>
@foreach (var cat in _categories)
{
<button @onclick='() => _selectedCategory = cat.Id'
style="white-space:nowrap;padding:8px 16px;border-radius:20px;border:none;font-size:13px;font-weight:600;cursor:pointer;@(_selectedCategory == cat.Id ? "background:#F59E0B;color:white;" : "background:#F3F4F6;color:#6B7280;")">
@cat.Name
</button>
}
</div>
}
@* ═══ PRODUCTS ═══ *@
@if (!FilteredProducts.Any())
{
<div style="text-align:center;padding:40px;color:#9CA3AF;">
<p style="font-size:48px;margin:0;">🍽️</p>
<p style="margin-top:8px;">Chưa có sản phẩm nào</p>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
@foreach (var product in FilteredProducts)
{
var inCart = _cart.FirstOrDefault(c => c.ProductId == product.Id);
<div style="background:white;border-radius:14px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);border:1px solid #F3F4F6;">
<div style="padding:16px;text-align:center;">
<div style="font-size:14px;font-weight:600;color:#1F2937;margin-bottom:4px;line-height:1.3;">@product.Name</div>
@if (!string.IsNullOrEmpty(product.Description))
{
<div style="font-size:11px;color:#9CA3AF;margin-bottom:8px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@product.Description</div>
}
<div style="font-size:15px;font-weight:700;color:#F59E0B;margin-bottom:10px;">@FormatVND(product.Price)</div>
@if (inCart != null)
{
<div style="display:flex;align-items:center;justify-content:center;gap:12px;">
<button @onclick='() => UpdateCartQty(product.Id, -1)' style="width:32px;height:32px;border-radius:50%;border:2px solid #E5E7EB;background:white;color:#374151;font-size:16px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;"></button>
<span style="font-size:16px;font-weight:700;color:#1F2937;min-width:20px;text-align:center;">@inCart.Qty</span>
<button @onclick='() => UpdateCartQty(product.Id, 1)' style="width:32px;height:32px;border-radius:50%;border:none;background:#F59E0B;color:white;font-size:16px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;">+</button>
</div>
}
else
{
<button @onclick='() => AddToCart(product)' style="width:100%;padding:8px;border-radius:10px;border:none;background:#FEF3C7;color:#92400E;font-weight:600;font-size:13px;cursor:pointer;">
+ Thêm
</button>
}
</div>
</div>
}
</div>
}
</div>
@* ═══ CART PANEL ═══ *@
@if (_showCart && _cart.Any())
{
<div style="position:fixed;bottom:0;left:0;right:0;background:white;box-shadow:0 -4px 16px rgba(0,0,0,0.1);border-radius:20px 20px 0 0;z-index:100;max-height:60vh;overflow-y:auto;">
<div style="max-width:640px;margin:0 auto;padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="font-size:18px;font-weight:700;color:#1F2937;margin:0;">Giỏ hàng</h3>
<button @onclick='() => _showCart = false' style="background:none;border:none;font-size:20px;color:#9CA3AF;cursor:pointer;">✕</button>
</div>
@foreach (var item in _cart)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #F3F4F6;">
<div>
<div style="font-size:14px;font-weight:600;color:#1F2937;">@item.Name</div>
<div style="font-size:12px;color:#9CA3AF;">@FormatVND(item.Price) × @item.Qty</div>
</div>
<div style="font-size:14px;font-weight:700;color:#1F2937;">@FormatVND(item.Price * item.Qty)</div>
</div>
}
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:16px;margin-top:8px;">
<span style="font-size:16px;font-weight:700;color:#1F2937;">Tổng cộng</span>
<span style="font-size:20px;font-weight:700;color:#F59E0B;">@FormatVND(_cart.Sum(c => c.Price * c.Qty))</span>
</div>
<button @onclick="PlaceOrder" disabled="@_ordering" style="width:100%;padding:14px;border-radius:12px;border:none;background:#F59E0B;color:white;font-size:16px;font-weight:700;cursor:pointer;margin-top:16px;">
@(_ordering ? "Đang gửi..." : "Đặt món")
</button>
@if (_orderMessage != null)
{
<div style="margin-top:8px;text-align:center;font-size:14px;color:@(_orderSuccess ? "#059669" : "#DC2626");">@_orderMessage</div>
}
</div>
</div>
}
@* ═══ STAFF CALLED TOAST ═══ *@
@if (_staffCalled)
{
<div style="position:fixed;top:80px;left:50%;transform:translateX(-50%);background:#059669;color:white;padding:12px 24px;border-radius:12px;font-size:14px;font-weight:600;z-index:200;box-shadow:0 4px 12px rgba(0,0,0,0.15);">
Đã thông báo nhân viên!
</div>
}
}
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
[Parameter] public string Token { get; set; } = "";
private bool _loading = true;
private string? _error;
private Guid _shopId;
private Guid _tableId;
private string _tableNumber = "";
private string _shopName = "";
private List<PosDataService.ProductInfo> _products = new();
private List<PosDataService.CategoryInfo> _categories = new();
private Guid? _selectedCategory;
private readonly List<CartItem> _cart = new();
private bool _showCart;
private bool _ordering;
private string? _orderMessage;
private bool _orderSuccess;
private bool _staffCalled;
private IEnumerable<PosDataService.ProductInfo> FilteredProducts =>
_selectedCategory == null ? _products : _products.Where(p => p.CategoryId == _selectedCategory);
protected override async Task OnInitializedAsync()
{
try
{
var tableInfo = await DataService.GetTableByTokenAsync(Token);
if (tableInfo == null)
{
_error = "Mã QR không hợp lệ hoặc đã hết hạn.";
_loading = false;
return;
}
_tableId = tableInfo.Id;
_shopId = tableInfo.ShopId;
_tableNumber = tableInfo.TableNumber;
var shop = await DataService.GetShopByIdAsync(_shopId);
_shopName = shop?.Name ?? "Nhà hàng";
_products = await DataService.GetProductsAsync(_shopId);
_categories = await DataService.GetCategoriesAsync(_shopId);
}
catch
{
_error = "Không thể tải menu. Vui lòng thử lại.";
}
_loading = false;
}
private void AddToCart(PosDataService.ProductInfo product)
{
_cart.Add(new CartItem(product.Id, product.Name, product.Price, 1));
}
private void UpdateCartQty(Guid productId, int delta)
{
var item = _cart.FirstOrDefault(c => c.ProductId == productId);
if (item == null) return;
item.Qty += delta;
if (item.Qty <= 0) _cart.Remove(item);
}
private async Task PlaceOrder()
{
_ordering = true;
_orderMessage = null;
try
{
var items = _cart.Select(c => new PosDataService.PosOrderItemRequest(
c.ProductId, c.Name, c.Qty, c.Price, "PreparedFood")).ToList();
var req = new PosDataService.CreatePosOrderRequest(
_shopId, null, items, null, null, null, _tableId);
var result = await DataService.CreatePosOrderAsync(req);
if (result != null)
{
_orderMessage = "Đặt món thành công! Món sẽ được phục vụ sớm nhất.";
_orderSuccess = true;
_cart.Clear();
_showCart = false;
}
else
{
_orderMessage = "Không thể đặt món. Vui lòng thử lại.";
_orderSuccess = false;
}
}
catch
{
_orderMessage = "Lỗi khi đặt món. Vui lòng thử lại.";
_orderSuccess = false;
}
_ordering = false;
}
private async Task CallStaff()
{
_staffCalled = true;
StateHasChanged();
await Task.Delay(3000);
_staffCalled = false;
StateHasChanged();
}
private static string FormatVND(decimal amount) => $"{amount:N0}đ";
private class CartItem
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int Qty { get; set; }
public CartItem(Guid productId, string name, decimal price, int qty)
{ ProductId = productId; Name = name; Price = price; Qty = qty; }
}
}

View File

@@ -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, int? PositionX = null, int? PositionY = null);
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, string? QrToken = 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,
@@ -1009,6 +1009,36 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
// ═══ TABLE QR ═══
public async Task<string?> GenerateTableQrTokenAsync(Guid tableId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/generate-qr", new { }, _writeOptions);
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data) && data.TryGetProperty("qrToken", out var token))
return token.GetString();
}
return null;
}
public record TableByTokenInfo(Guid Id, Guid ShopId, string TableNumber, int Capacity, string? Zone);
public async Task<TableByTokenInfo?> GetTableByTokenAsync(string token)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/tables/by-token/{token}");
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<TableByTokenInfo>(data.GetRawText(), _jsonOptions);
}
return null;
}
// ═══ RESERVATIONS ═══
public record ReservationInfo(Guid Id, Guid ShopId, Guid? TableId, string GuestName, string? Phone,

View File

@@ -119,6 +119,16 @@ public class FnbController : ControllerBase
public Task<IActionResult> CreateReservation([FromBody] JsonElement body) =>
_fnb.PostAsJsonAsync("/api/v1/reservations", body).ProxyAsync();
// ═══ TABLE QR ═══
[HttpPost("tables/{tableId:guid}/generate-qr")]
public Task<IActionResult> GenerateTableQr(Guid tableId) =>
_fnb.PostAsJsonAsync($"/api/v1/tables/{tableId}/generate-qr", new { }).ProxyAsync();
[HttpGet("tables/by-token/{token}")]
public Task<IActionResult> GetTableByToken(string token) =>
_fnb.GetAsync($"/api/v1/tables/by-token/{token}").ProxyAsync();
[HttpPatch("reservations/{id:guid}/status")]
public Task<IActionResult> UpdateReservationStatus(Guid id, [FromBody] JsonElement body)
{

View File

@@ -23,5 +23,6 @@ public record TableDto(
string? Zone,
string Status,
int? PositionX = null,
int? PositionY = null
int? PositionY = null,
string? QrToken = null
);

View File

@@ -35,7 +35,8 @@ public class GetTablesQueryHandler : IRequestHandler<GetTablesQuery, IEnumerable
t.Zone,
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available",
t.PositionX,
t.PositionY
t.PositionY,
t.QrToken
));
}
}

View File

@@ -6,6 +6,7 @@ using MediatR;
using Microsoft.AspNetCore.Mvc;
using FnbEngine.API.Application.Commands;
using FnbEngine.API.Application.Queries;
using FnbEngine.Domain.AggregatesModel.TableAggregate;
namespace FnbEngine.API.Controllers;
@@ -20,11 +21,13 @@ public class TablesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<TablesController> _logger;
private readonly ITableRepository _tableRepository;
public TablesController(IMediator mediator, ILogger<TablesController> logger)
public TablesController(IMediator mediator, ILogger<TablesController> logger, ITableRepository tableRepository)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_tableRepository = tableRepository ?? throw new ArgumentNullException(nameof(tableRepository));
}
/// <summary>
@@ -102,6 +105,49 @@ public class TablesController : ControllerBase
var result = await _mediator.Send(command, ct);
return Ok(new ApiResponse<bool> { Success = true, Data = result });
}
/// <summary>
/// EN: Generate QR token for a table.
/// VI: Tạo QR token cho bàn.
/// </summary>
[HttpPost("{id}/generate-qr")]
[ProducesResponseType(typeof(ApiResponse<object>), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<ApiResponse<object>>> GenerateQrToken(
Guid id,
CancellationToken ct = default)
{
var table = await _tableRepository.GetByIdAsync(id, ct);
if (table is null)
return NotFound(new ApiResponse<object> { Success = false, Error = "Table not found" });
var token = table.GenerateQrToken();
_tableRepository.Update(table);
await _tableRepository.UnitOfWork.SaveEntitiesAsync(ct);
return Ok(new ApiResponse<object> { Success = true, Data = new { qrToken = token } });
}
/// <summary>
/// EN: Get table by QR token (public, no auth).
/// VI: Lấy bàn theo QR token (public, không cần auth).
/// </summary>
[HttpGet("by-token/{token}")]
[ProducesResponseType(typeof(ApiResponse<object>), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<ApiResponse<object>>> GetByQrToken(
string token,
CancellationToken ct = default)
{
var table = await _tableRepository.GetByQrTokenAsync(token, ct);
if (table is null)
return NotFound(new ApiResponse<object> { Success = false, Error = "Table not found" });
return Ok(new ApiResponse<object>
{
Success = true,
Data = new { table.Id, table.ShopId, table.TableNumber, table.Capacity, table.Zone }
});
}
}
/// <summary>

View File

@@ -40,4 +40,10 @@ public interface ITableRepository : IRepository<Table>
/// VI: Lấy bàn theo shop ID và số bàn.
/// </summary>
Task<Table?> GetByNumberAsync(Guid shopId, string tableNumber, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get table by QR token.
/// VI: Lấy bàn theo QR token.
/// </summary>
Task<Table?> GetByQrTokenAsync(string qrToken, CancellationToken cancellationToken = default);
}

View File

@@ -19,6 +19,7 @@ public class Table : Entity, IAggregateRoot
private TableStatus _status = null!;
private int? _positionX;
private int? _positionY;
private string? _qrToken;
private DateTime _createdAt;
private DateTime? _updatedAt;
@@ -30,6 +31,7 @@ public class Table : Entity, IAggregateRoot
public int StatusId { get; private set; }
public int? PositionX => _positionX;
public int? PositionY => _positionY;
public string? QrToken => _qrToken;
public DateTime CreatedAt => _createdAt;
public DateTime? UpdatedAt => _updatedAt;
@@ -86,4 +88,17 @@ public class Table : Entity, IAggregateRoot
_positionY = y;
_updatedAt = DateTime.UtcNow;
}
public string GenerateQrToken()
{
_qrToken = Guid.NewGuid().ToString("N")[..16];
_updatedAt = DateTime.UtcNow;
return _qrToken;
}
public void ClearQrToken()
{
_qrToken = null;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -53,6 +53,11 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration<Table>
.HasField("_positionY")
.HasColumnName("position_y");
builder.Property(t => t.QrToken)
.HasField("_qrToken")
.HasColumnName("qr_token")
.HasMaxLength(64);
builder.Property(t => t.CreatedAt)
.HasField("_createdAt")
.HasColumnName("created_at")
@@ -70,6 +75,11 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration<Table>
.HasDatabaseName("ix_tables_shop_table_number")
.IsUnique();
builder.HasIndex(t => t.QrToken)
.HasDatabaseName("ix_tables_qr_token")
.IsUnique()
.HasFilter("qr_token IS NOT NULL");
// EN: Ignore the Status navigation (enumeration loaded via StatusId)
// VI: Bỏ qua navigation Status (enumeration được load qua StatusId)
builder.Ignore(t => t.Status);

View File

@@ -51,4 +51,10 @@ public class TableRepository : ITableRepository
return await _context.Tables
.FirstOrDefaultAsync(t => t.ShopId == shopId && t.TableNumber == tableNumber, cancellationToken);
}
public async Task<Table?> GetByQrTokenAsync(string qrToken, CancellationToken cancellationToken = default)
{
return await _context.Tables
.FirstOrDefaultAsync(t => t.QrToken == qrToken, cancellationToken);
}
}