feat: Phase 2 close-out — multi-branch management, production K8s, revenue dashboard UI, responsive POS

Backend:
- Multi-branch shop management: SetDefaultShop, TransferShop commands, GetMerchantShops paginated query
- Shop aggregate: IsDefault field, SetAsDefault/ClearDefault/TransferOwnership behavior methods
- 2 new domain events: ShopSetAsDefaultDomainEvent, ShopTransferredDomainEvent

Frontend:
- Revenue Dashboard (MudChart line/donut/bar, 4 KPI cards, top products table)
- Staff Performance (sortable table, color-coded completion rates, CSV export)
- Customer QR Menu page (/menu/{ShopId}, mobile-first, Vietnamese labels)
- QR Code Generator admin page (batch generate, print-all, per-table QR)
- Responsive POS layout (collapsible sidebar, slide-out order drawer, touch-friendly CSS)
- ResponsiveOrderPanel component (desktop inline / tablet drawer / mobile overlay)

Infrastructure:
- Production K8s manifests: 8 services (3 replicas, 512Mi-1Gi, HPA min3/max10), Redis with persistence
- Production ingress: api.goodgo.vn, cert-manager TLS, rate-limit middleware
- Deploy script: pre-flight checks, dry-run, single-service deploy, rollback support
- CI/CD: deploy-production.yml with environment approval, commit SHA tags
- Prometheus full scrape config (11 targets), docker-compose observability stack
- Production deployment checklist (80+ items)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-06 19:58:40 +07:00
parent a6ea9fa29b
commit 76b5e6afd0
40 changed files with 5582 additions and 165 deletions

View File

@@ -0,0 +1,63 @@
@*
EN: Responsive Order Panel — Adapts to screen size automatically.
Desktop: Renders inline as a right sidebar (pos-cart-panel).
Tablet/Mobile: Content is injected into PosLayout's order drawer via CascadingValue.
VI: Panel Đơn Hàng Responsive — Tự động thích ứng theo kích thước màn hình.
Desktop: Render inline dạng sidebar phải (pos-cart-panel).
Tablet/Mobile: Nội dung được inject vào order drawer của PosLayout qua CascadingValue.
*@
@inject IJSRuntime JS
@implements IDisposable
@* EN: Desktop view — inline sidebar / VI: View desktop — sidebar inline *@
<div class="pos-cart-panel pos-cart-panel--desktop">
@ChildContent
</div>
@* EN: Mobile/Tablet — inject content into layout drawer
VI: Mobile/Tablet — inject nội dung vào drawer của layout *@
@code {
/// <summary>
/// EN: The order panel content (cart items, totals, checkout button).
/// VI: Nội dung panel đơn hàng (mục giỏ hàng, tổng, nút thanh toán).
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// EN: Number of items in cart — shown as badge on mobile toggle.
/// VI: Số mục trong giỏ hàng — hiện badge trên toggle mobile.
/// </summary>
[Parameter] public int ItemCount { get; set; }
/// <summary>
/// EN: Reference to parent PosLayout for setting drawer content.
/// VI: Tham chiếu đến PosLayout cha để set nội dung drawer.
/// </summary>
[CascadingParameter] public WebClientTpos.Client.Layout.PosLayout? Layout { get; set; }
private bool _contentSet;
protected override void OnParametersSet()
{
// EN: Push content to layout's order drawer for tablet/mobile
// VI: Đẩy nội dung vào order drawer của layout cho tablet/mobile
if (Layout is not null)
{
Layout.OrderPanelContent = ChildContent;
Layout.SetOrderCount(ItemCount);
_contentSet = true;
}
}
public void Dispose()
{
// EN: Clean up layout reference when component is disposed
// VI: Dọn dẹp tham chiếu layout khi component bị dispose
if (_contentSet && Layout is not null)
{
Layout.OrderPanelContent = null;
Layout.SetOrderCount(0);
}
}
}

View File

@@ -1,12 +1,16 @@
@*
EN: POS terminal layout — Full-screen, status bar + content, touch-friendly.
VI: Layout POS — Toàn màn hình, thanh trạng thái + nội dung, thân thiện cảm ứng.
EN: POS terminal layout — Responsive full-screen layout with status bar + content.
Desktop: Full sidebar + content. Tablet: Collapsible order drawer. Mobile: Bottom nav + full-screen overlay.
VI: Layout POS — Layout toàn màn hình responsive với thanh trạng thái + nội dung.
Desktop: Sidebar đầy đủ + nội dung. Tablet: Drawer đơn hàng thu gọn. Mobile: Nav dưới + overlay toàn màn hình.
Design: pencil-design/src/pages/tPOS/pos/cafe/desktop.pen
*@
@inherits LayoutComponentBase
@implements IDisposable
@inject IStringLocalizer<PosLayout> L
@inject NavigationManager NavigationManager
@inject WebClientTpos.Client.Services.PosDataService DataService
@inject IJSRuntime JS
<MudThemeProvider IsDarkMode="true" Theme="AppTheme.DefaultDark" />
<MudPopoverProvider />
@@ -17,6 +21,10 @@
@* ═══ STATUS BAR ═══ *@
<header class="pos-status-bar">
<div class="pos-status-bar__left">
@* EN: Hamburger menu — visible on tablet/mobile only / VI: Menu hamburger — chi hien thi tren tablet/mobile *@
<button class="pos-mobile-toggle" @onclick="ToggleSidebar" title="Menu">
<i data-lucide="@(_sidebarOpen ? "x" : "menu")" style="width:20px;height:20px;"></i>
</button>
<span class="pos-status-bar__logo">GoodGo POS</span>
<span class="pos-status-bar__store">@StoreName</span>
</div>
@@ -25,8 +33,17 @@
<span style="width:6px;height:6px;border-radius:100px;background:currentColor;"></span>
<span>Online</span>
</div>
<span style="font-size:13px;color:var(--pos-text-secondary);">@_currentTime</span>
<button class="admin-icon-btn" @onclick="GoToAdmin" title="Admin">
<span class="pos-status-bar__time">@_currentTime</span>
@* EN: Order panel toggle — visible on tablet/mobile when order panel is hidden
VI: Nút mở panel đơn hàng — hiện trên tablet/mobile khi panel đơn hàng ẩn *@
<button class="pos-order-toggle" @onclick="ToggleOrderPanel" title="Đơn hàng">
<i data-lucide="shopping-cart" style="width:18px;height:18px;"></i>
@if (_orderCount > 0)
{
<span class="pos-order-toggle__badge">@_orderCount</span>
}
</button>
<button class="admin-icon-btn pos-admin-btn" @onclick="GoToAdmin" title="Admin">
<i data-lucide="settings"></i>
</button>
</div>
@@ -34,21 +51,129 @@
@* ═══ MAIN CONTENT ═══ *@
<div class="pos-main">
@Body
@* EN: Mobile sidebar overlay / VI: Overlay sidebar trên mobile *@
@if (_sidebarOpen)
{
<div class="pos-sidebar-overlay" @onclick="CloseSidebar"></div>
}
@* EN: Sidebar navigation — collapsible on tablet/mobile
VI: Sidebar điều hướng — thu gọn trên tablet/mobile *@
<nav class="pos-sidebar @(_sidebarOpen ? "pos-sidebar--open" : "")">
<div class="pos-sidebar__header">
<span style="font-size:15px;font-weight:700;color:var(--pos-orange-primary);">Menu</span>
<button class="pos-sidebar__close" @onclick="CloseSidebar">
<i data-lucide="x" style="width:18px;height:18px;"></i>
</button>
</div>
<div class="pos-sidebar__nav">
<a class="pos-sidebar__link" href="/pos/@_shopIdStr/karaoke" @onclick="CloseSidebar">
<i data-lucide="mic" style="width:18px;height:18px;"></i>
<span>Karaoke</span>
</a>
<a class="pos-sidebar__link" href="/pos/@_shopIdStr/restaurant" @onclick="CloseSidebar">
<i data-lucide="utensils" style="width:18px;height:18px;"></i>
<span>Nhà hàng</span>
</a>
<a class="pos-sidebar__link" href="/pos/@_shopIdStr/cafe" @onclick="CloseSidebar">
<i data-lucide="coffee" style="width:18px;height:18px;"></i>
<span>Café</span>
</a>
<a class="pos-sidebar__link" href="/pos/@_shopIdStr/spa" @onclick="CloseSidebar">
<i data-lucide="sparkles" style="width:18px;height:18px;"></i>
<span>Spa</span>
</a>
<a class="pos-sidebar__link" href="/pos/@_shopIdStr/retail" @onclick="CloseSidebar">
<i data-lucide="shopping-bag" style="width:18px;height:18px;"></i>
<span>Bán lẻ</span>
</a>
</div>
<div class="pos-sidebar__footer">
<a class="pos-sidebar__link" href="/admin" @onclick="CloseSidebar">
<i data-lucide="settings" style="width:18px;height:18px;"></i>
<span>Quản trị</span>
</a>
</div>
</nav>
@* EN: Page content area / VI: Vùng nội dung trang *@
<div class="pos-page-content">
<CascadingValue Value="this">
@Body
</CascadingValue>
</div>
@* EN: Order panel drawer — slides in from right on tablet/mobile
VI: Drawer panel đơn hàng — trượt vào từ phải trên tablet/mobile *@
@if (_orderPanelOpen)
{
<div class="pos-order-overlay" @onclick="CloseOrderPanel"></div>
}
<div class="pos-order-drawer @(_orderPanelOpen ? "pos-order-drawer--open" : "")">
<div class="pos-order-drawer__header">
<span style="font-size:15px;font-weight:700;">Đơn hàng</span>
<button class="pos-order-drawer__close" @onclick="CloseOrderPanel">
<i data-lucide="x" style="width:18px;height:18px;"></i>
</button>
</div>
<div class="pos-order-drawer__content" id="pos-order-drawer-content">
@* EN: Order panel content is rendered by child pages via RenderFragment
VI: Nội dung panel đơn hàng được render bởi trang con qua RenderFragment *@
@if (OrderPanelContent is not null)
{
@OrderPanelContent
}
else
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);font-size:14px;padding:32px;">
Chưa có đơn hàng
</div>
}
</div>
</div>
</div>
</div>
@implements IDisposable
@code {
private string StoreName { get; set; } = "GoodGo POS";
private string _currentTime = DateTime.Now.ToString("HH:mm");
private Timer? _timer;
private string _shopIdStr = "";
// EN: Responsive state / VI: Trạng thái responsive
private bool _sidebarOpen;
private bool _orderPanelOpen;
private int _orderCount;
/// <summary>
/// EN: Optional order panel content — set by child POS pages via CascadingValue.
/// VI: Nội dung panel đơn hàng tùy chọn — set bởi trang con POS qua CascadingValue.
/// </summary>
public RenderFragment? OrderPanelContent { get; set; }
/// <summary>
/// EN: Set order count badge on the order toggle button.
/// VI: Đặt badge số lượng đơn hàng trên nút toggle đơn hàng.
/// </summary>
public void SetOrderCount(int count)
{
_orderCount = count;
StateHasChanged();
}
/// <summary>
/// EN: Open order panel drawer (tablet/mobile).
/// VI: Mở drawer panel đơn hàng (tablet/mobile).
/// </summary>
public void OpenOrderPanel()
{
_orderPanelOpen = true;
StateHasChanged();
}
protected override async Task OnInitializedAsync()
{
// EN: Update clock every 30 seconds
// VI: Cập nhật đồng hồ mỗi 30 giây
// EN: Update clock every 30 seconds / VI: Cập nhật đồng hồ mỗi 30 giây
_timer = new Timer(_ =>
{
_currentTime = DateTime.Now.ToString("HH:mm");
@@ -64,6 +189,7 @@
// Expected: ["pos", "{shopId}", "cafe"|"restaurant"|...]
if (segments.Length >= 2 && Guid.TryParse(segments[1], out var shopId))
{
_shopIdStr = shopId.ToString();
var shop = await DataService.GetShopByIdAsync(shopId);
if (shop != null)
StoreName = shop.Name;
@@ -75,9 +201,20 @@
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// EN: Re-init Lucide icons after every render (Blazor navigation replaces DOM)
// VI: Khởi tạo lại Lucide icons sau mỗi lần render
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private void GoToAdmin() => NavigationManager.NavigateTo("/admin");
private void ToggleSidebar() => _sidebarOpen = !_sidebarOpen;
private void CloseSidebar() => _sidebarOpen = false;
private void ToggleOrderPanel() => _orderPanelOpen = !_orderPanelOpen;
private void CloseOrderPanel() => _orderPanelOpen = false;
public void Dispose() => _timer?.Dispose();
}

View File

@@ -0,0 +1,19 @@
/* ═════════════════════════════════════════════════════════════════════════
POS Layout — Scoped CSS
EN: Layout-specific overrides that complement pos.css responsive styles.
VI: Override riêng cho layout, bổ sung cho styles responsive pos.css.
═════════════════════════════════════════════════════════════════════════ */
/* EN: Ensure smooth transitions for all interactive elements
VI: Đảm bảo transition mượt cho tất cả phần tử tương tác */
::deep .pos-sidebar,
::deep .pos-order-drawer {
will-change: transform;
}
/* EN: Prevent body scroll when overlay is open
VI: Ngăn cuộn body khi overlay đang mở */
::deep .pos-sidebar-overlay,
::deep .pos-order-overlay {
backdrop-filter: blur(2px);
}

View File

@@ -0,0 +1,465 @@
@page "/admin/reports/revenue"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@inject ISnackbar Snackbar
@using WebClientTpos.Client.Services
@*
EN: Revenue Analytics Dashboard — trends, payment methods, top products, vertical breakdown.
VI: Trang phan tich doanh thu — xu huong, phuong thuc thanh toan, san pham ban chay, phan tich nganh.
*@
<PageTitle>Phan tich doanh thu — GoodGo POS</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Phan tich doanh thu</h1>
<p class="admin-topbar__subtitle">Xu huong doanh thu, phuong thuc thanh toan va san pham ban chay</p>
</div>
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
<MudDateRangePicker @bind-DateRange="_dateRange"
Label="Khoang thoi gian"
Variant="Variant.Outlined"
DateFormat="dd/MM/yyyy"
MaxDate="DateTime.Today"
Style="max-width:280px;"
Color="Color.Primary"
Class="mud-input-dark" />
<MudChipSet T="string" @bind-SelectedValue="_selectedPeriod" Mandatory="true" Class="ml-2">
<MudChip T="string" Value="@("daily")" Color="Color.Primary" Variant="Variant.Text">Ngay</MudChip>
<MudChip T="string" Value="@("weekly")" Color="Color.Primary" Variant="Variant.Text">Tuan</MudChip>
<MudChip T="string" Value="@("monthly")" Color="Color.Primary" Variant="Variant.Text">Thang</MudChip>
</MudChipSet>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="LoadDataAsync"
Disabled="_loading"
StartIcon="@Icons.Material.Filled.Analytics">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
}
Xem phan tich
</MudButton>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (_loading)
{
<div style="display:flex;justify-content:center;padding:48px;">
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
</div>
}
else if (_data == null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
Chon khoang thoi gian va nhan "Xem phan tich" de tai du lieu doanh thu.
</MudAlert>
}
else
{
@* ── KPI SUMMARY CARDS ── *@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
@* Total Revenue *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
<i data-lucide="trending-up" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_data.TotalRevenue)</span>
<span class="admin-stat-card__label">Tong doanh thu</span>
@if (_data.PreviousPeriodRevenue > 0)
{
<span style="font-size:11px;color:var(--admin-text-tertiary);">Ky truoc: @FormatVND(_data.PreviousPeriodRevenue)</span>
}
</div>
</div>
@* Total Orders *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);">
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_data.TotalOrders</span>
<span class="admin-stat-card__label">Tong don hang</span>
</div>
</div>
@* Average Order Value *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);">
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_data.AverageOrderValue)</span>
<span class="admin-stat-card__label">Gia tri TB / don</span>
</div>
</div>
@* Growth % *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:@(_data.GrowthPercentage >= 0 ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");">
<i data-lucide="@(_data.GrowthPercentage >= 0 ? "arrow-up-right" : "arrow-down-right")"
style="color:@(_data.GrowthPercentage >= 0 ? "#22C55E" : "#EF4444");"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value" style="color:@(_data.GrowthPercentage >= 0 ? "#22C55E" : "#EF4444");">
@(_data.GrowthPercentage >= 0 ? "+" : "")@_data.GrowthPercentage.ToString("N1")%
</span>
<span class="admin-stat-card__label">Tang truong so voi ky truoc</span>
</div>
</div>
</div>
@* ── REVENUE TREND CHART ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Xu huong doanh thu</h3>
</div>
<div class="admin-panel__body">
@if (_trendChartSeries.Any())
{
<MudChart ChartType="ChartType.Line"
ChartSeries="@_trendChartSeries"
XAxisLabels="@_trendChartLabels"
Width="100%"
Height="350px"
ChartOptions="_lineOptions" />
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu xu huong.</div>
}
</div>
</div>
@* ── PAYMENT METHODS & VERTICAL BREAKDOWN ── *@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
@* Payment Method Donut *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Phuong thuc thanh toan</h3>
</div>
<div class="admin-panel__body">
@if (_data.PaymentMethods.Any())
{
<div style="display:flex;gap:24px;align-items:center;">
<div style="flex:1;">
<MudChart ChartType="ChartType.Donut"
InputData="@_paymentChartData"
InputLabels="@_paymentChartLabels"
Width="220px"
Height="220px"
ChartOptions="_donutOptions" />
</div>
<div style="flex:1;">
@foreach (var p in _data.PaymentMethods)
{
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--admin-border-subtle);">
<div>
<span style="font-weight:600;color:var(--admin-text-primary);">@GetPaymentMethodLabel(p.Method)</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">@p.Percentage%</span>
</div>
<span style="font-weight:700;color:var(--admin-orange-primary);">@FormatVND(p.Amount)</span>
</div>
}
</div>
</div>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu thanh toan.</div>
}
</div>
</div>
@* Vertical Revenue Bar Chart *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo nganh</h3>
</div>
<div class="admin-panel__body">
@if (_verticalChartSeries.Any())
{
<MudChart ChartType="ChartType.Bar"
ChartSeries="@_verticalChartSeries"
XAxisLabels="@_verticalChartLabels"
Width="100%"
Height="300px"
ChartOptions="_barOptions" />
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu nganh.</div>
}
</div>
</div>
</div>
@* ── ORDER COUNT DISTRIBUTION ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Phan bo don hang theo thoi gian</h3>
</div>
<div class="admin-panel__body">
@if (_orderCountChartSeries.Any())
{
<MudChart ChartType="ChartType.Bar"
ChartSeries="@_orderCountChartSeries"
XAxisLabels="@_trendChartLabels"
Width="100%"
Height="280px"
ChartOptions="_orderCountBarOptions" />
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu phan bo don hang.</div>
}
</div>
</div>
@* ── TOP 10 PRODUCTS TABLE ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Top 10 san pham ban chay</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_data.TopProducts.Any())
{
<MudTable Items="@_data.TopProducts"
Dense="true"
Hover="true"
Striped="true"
Elevation="0"
Class="mud-table-dark">
<HeaderContent>
<MudTh Style="font-weight:700;">#</MudTh>
<MudTh Style="font-weight:700;">Ten san pham</MudTh>
<MudTh Style="font-weight:700;text-align:right;">So luong ban</MudTh>
<MudTh Style="font-weight:700;text-align:right;">Doanh thu</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd Style="font-weight:700;color:var(--admin-orange-primary);">@(_data.TopProducts.IndexOf(context) + 1)</MudTd>
<MudTd Style="font-weight:600;">@context.Name</MudTd>
<MudTd Style="text-align:right;font-weight:600;color:#3B82F6;">@context.QuantitySold</MudTd>
<MudTd Style="text-align:right;font-weight:700;color:var(--admin-orange-primary);">@FormatVND(context.Revenue)</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu san pham.</div>
}
</div>
</div>
}
</div>
@code {
// EN: Date range for the report / VI: Khoang thoi gian cho bao cao
private DateRange _dateRange = new(DateTime.Today.AddDays(-30), DateTime.Today);
private string _selectedPeriod = "daily";
private bool _loading = false;
private PosDataService.RevenueAnalyticsInfo? _data;
// EN: Chart data arrays / VI: Mang du lieu bieu do
private List<ChartSeries> _trendChartSeries = new();
private string[] _trendChartLabels = Array.Empty<string>();
private double[] _paymentChartData = Array.Empty<double>();
private string[] _paymentChartLabels = Array.Empty<string>();
private List<ChartSeries> _verticalChartSeries = new();
private string[] _verticalChartLabels = Array.Empty<string>();
private List<ChartSeries> _orderCountChartSeries = new();
private ChartOptions _lineOptions = new()
{
ChartPalette = new[] { "#FF5C00", "#3B82F6" },
YAxisTicks = 5,
LineStrokeWidth = 2.5
};
private ChartOptions _donutOptions = new()
{
ChartPalette = new[] { "#22C55E", "#3B82F6", "#8B5CF6", "#F59E0B", "#EC4899", "#FF5C00" }
};
private ChartOptions _barOptions = new()
{
ChartPalette = new[] { "#FF5C00", "#3B82F6", "#22C55E", "#8B5CF6", "#F59E0B" },
YAxisTicks = 5
};
private ChartOptions _orderCountBarOptions = new()
{
ChartPalette = new[] { "#3B82F6" },
YAxisTicks = 5
};
/// <summary>
/// EN: Get the current shop ID from query string context.
/// VI: Lay shop ID hien tai tu query string context.
/// </summary>
private Guid? GetCurrentShopId()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
if (Guid.TryParse(query["shopId"], out var qsShopId))
return qsShopId;
return null;
}
/// <summary>
/// EN: Load revenue analytics data.
/// VI: Tai du lieu phan tich doanh thu.
/// </summary>
private async Task LoadDataAsync()
{
var shopId = GetCurrentShopId();
if (!shopId.HasValue)
{
Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning);
return;
}
if (_dateRange.Start == null || _dateRange.End == null)
{
Snackbar.Add("Vui long chon khoang thoi gian.", Severity.Warning);
return;
}
_loading = true;
_data = null;
StateHasChanged();
try
{
_data = await DataService.GetRevenueAnalyticsAsync(
shopId.Value,
_dateRange.Start.Value,
_dateRange.End.Value,
_selectedPeriod);
if (_data != null)
{
BuildChartData();
Snackbar.Add("Da tai phan tich doanh thu thanh cong.", Severity.Success);
}
else
{
Snackbar.Add("Khong the tai du lieu. Vui long thu lai.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
StateHasChanged();
}
}
/// <summary>
/// EN: Build chart data arrays from analytics data.
/// VI: Xay dung mang du lieu bieu do tu du lieu phan tich.
/// </summary>
private void BuildChartData()
{
if (_data == null) return;
// EN: Revenue trend line chart / VI: Bieu do duong xu huong doanh thu
if (_data.Trends.Any())
{
_trendChartLabels = _data.Trends.Select(t => FormatTrendLabel(t.Date)).ToArray();
_trendChartSeries = new List<ChartSeries>
{
new ChartSeries { Name = "Doanh thu", Data = _data.Trends.Select(t => (double)t.Revenue).ToArray() }
};
_orderCountChartSeries = new List<ChartSeries>
{
new ChartSeries { Name = "So don hang", Data = _data.Trends.Select(t => (double)t.OrderCount).ToArray() }
};
}
else
{
_trendChartLabels = Array.Empty<string>();
_trendChartSeries = new();
_orderCountChartSeries = new();
}
// EN: Payment method donut chart / VI: Bieu do tron phuong thuc thanh toan
if (_data.PaymentMethods.Any())
{
_paymentChartData = _data.PaymentMethods.Select(p => (double)p.Amount).ToArray();
_paymentChartLabels = _data.PaymentMethods.Select(p => GetPaymentMethodLabel(p.Method)).ToArray();
}
else
{
_paymentChartData = Array.Empty<double>();
_paymentChartLabels = Array.Empty<string>();
}
// EN: Vertical breakdown bar chart / VI: Bieu do cot phan tich nganh
if (_data.VerticalBreakdown.Any())
{
_verticalChartLabels = _data.VerticalBreakdown.Select(v => GetVerticalLabel(v.Vertical)).ToArray();
_verticalChartSeries = new List<ChartSeries>
{
new ChartSeries { Name = "Doanh thu", Data = _data.VerticalBreakdown.Select(v => (double)v.Revenue).ToArray() }
};
}
else
{
_verticalChartLabels = Array.Empty<string>();
_verticalChartSeries = new();
}
}
/// <summary>
/// EN: Format trend date label based on selected period.
/// VI: Dinh dang nhan ngay xu huong theo ky da chon.
/// </summary>
private string FormatTrendLabel(DateTime date) => _selectedPeriod switch
{
"monthly" => date.ToString("MM/yyyy"),
"weekly" => $"T{System.Globalization.ISOWeek.GetWeekOfYear(date)} {date:yyyy}",
_ => date.ToString("dd/MM")
};
/// <summary>
/// EN: Get Vietnamese label for payment method.
/// VI: Lay nhan tieng Viet cho phuong thuc thanh toan.
/// </summary>
private static string GetPaymentMethodLabel(string method) => method.ToLowerInvariant() switch
{
"cash" => "Tien mat",
"card" => "The",
"vnpay" => "VNPay",
"momo" => "MoMo",
"qr" => "QR Code",
"transfer" => "Chuyen khoan",
"unknown" => "Chua xac dinh",
_ => method
};
/// <summary>
/// EN: Get Vietnamese label for business vertical.
/// VI: Lay nhan tieng Viet cho nganh kinh doanh.
/// </summary>
private static string GetVerticalLabel(string vertical) => vertical.ToLowerInvariant() switch
{
"karaoke" => "Karaoke",
"restaurant" => "Nha hang",
"cafe" => "Cafe",
"spa" => "Spa",
"retail" => "Ban le",
"other" => "Khac",
_ => vertical
};
private static string FormatVND(decimal val) => val.ToString("N0") + " d";
}

View File

@@ -0,0 +1,360 @@
@page "/admin/reports/staff"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@inject ISnackbar Snackbar
@inject IJSRuntime JS
@using WebClientTpos.Client.Services
@using System.Text
@*
EN: Staff Performance Dashboard — orders handled, revenue, completion rates per staff member.
VI: Trang hieu suat nhan vien — don xu ly, doanh thu, ty le hoan thanh theo nhan vien.
*@
<PageTitle>Hieu suat nhan vien — GoodGo POS</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Hieu suat nhan vien</h1>
<p class="admin-topbar__subtitle">Phan tich hieu qua lam viec cua nhan vien theo don hang va doanh thu</p>
</div>
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
<MudDateRangePicker @bind-DateRange="_dateRange"
Label="Khoang thoi gian"
Variant="Variant.Outlined"
DateFormat="dd/MM/yyyy"
MaxDate="DateTime.Today"
Style="max-width:280px;"
Color="Color.Primary"
Class="mud-input-dark" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="LoadDataAsync"
Disabled="_loading"
StartIcon="@Icons.Material.Filled.People">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
}
Xem hieu suat
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
OnClick="ExportCsvAsync"
Disabled="_loading || _data == null"
StartIcon="@Icons.Material.Filled.Download">
Xuat CSV
</MudButton>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (_loading)
{
<div style="display:flex;justify-content:center;padding:48px;">
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
</div>
}
else if (_data == null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
Chon khoang thoi gian va nhan "Xem hieu suat" de tai du lieu hieu suat nhan vien.
</MudAlert>
}
else
{
@* ── SUMMARY CARDS ── *@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
@* Best Performer *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);">
<i data-lucide="trophy" style="color:#F59E0B;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@(_bestPerformer?.StaffName ?? "N/A")</span>
<span class="admin-stat-card__label">Nhan vien xuat sac nhat</span>
@if (_bestPerformer != null)
{
<span style="font-size:12px;color:var(--admin-text-tertiary);">@FormatVND(_bestPerformer.TotalRevenue) doanh thu</span>
}
</div>
</div>
@* Total Staff *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);">
<i data-lucide="users" style="color:#3B82F6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_data.Staff.Count</span>
<span class="admin-stat-card__label">Tong nhan vien</span>
</div>
</div>
@* Average Completion Rate *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
<i data-lucide="check-circle" style="color:#22C55E;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@(_data.ShopAverage.CompletionRate.ToString("F1"))%</span>
<span class="admin-stat-card__label">Ty le hoan thanh TB</span>
</div>
</div>
@* Average Handling Time *@
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);">
<i data-lucide="clock" style="color:#8B5CF6;"></i>
</div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@(_data.ShopAverage.AverageHandlingTimeMinutes.ToString("F1")) phut</span>
<span class="admin-stat-card__label">Thoi gian xu ly TB</span>
</div>
</div>
</div>
@* ── STAFF REVENUE COMPARISON CHART ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">So sanh doanh thu nhan vien</h3>
</div>
<div class="admin-panel__body">
@if (_revenueChartSeries.Any())
{
<MudChart ChartType="ChartType.Bar"
ChartSeries="@_revenueChartSeries"
XAxisLabels="@_revenueChartLabels"
Width="100%"
Height="300px"
ChartOptions="_barOptions" />
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);">Khong co du lieu de so sanh.</div>
}
</div>
</div>
@* ── STAFF PERFORMANCE TABLE ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Chi tiet hieu suat nhan vien</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<MudTable Items="@_data.Staff"
Dense="true"
Hover="true"
Striped="true"
Elevation="0"
SortLabel="Sap xep theo"
Class="mud-table-dark">
<HeaderContent>
<MudTh><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.StaffName)">Nhan vien</MudTableSortLabel></MudTh>
<MudTh Style="text-align:right;"><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.OrdersHandled)" InitialDirection="SortDirection.Descending">Don xu ly</MudTableSortLabel></MudTh>
<MudTh Style="text-align:right;"><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.TotalRevenue)">Doanh thu</MudTableSortLabel></MudTh>
<MudTh Style="text-align:right;"><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.AverageOrderValue)">GT TB / don</MudTableSortLabel></MudTh>
<MudTh Style="text-align:right;"><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.CompletionRate)">Ty le HT</MudTableSortLabel></MudTh>
<MudTh Style="text-align:right;"><MudTableSortLabel SortBy="new Func<PosDataService.StaffMetricsInfo, object>(x => x.AverageHandlingTimeMinutes)">TG xu ly TB</MudTableSortLabel></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd Style="@GetRowStyle(context)">
<div style="display:flex;align-items:center;gap:8px;">
@if (context == _bestPerformer)
{
<i data-lucide="trophy" style="color:#F59E0B;width:16px;height:16px;"></i>
}
<span style="font-weight:600;">@context.StaffName</span>
</div>
</MudTd>
<MudTd Style="text-align:right;font-weight:600;@GetRowStyle(context)">@context.OrdersHandled</MudTd>
<MudTd Style="text-align:right;font-weight:700;color:var(--admin-orange-primary);@GetRowStyle(context)">@FormatVND(context.TotalRevenue)</MudTd>
<MudTd Style="text-align:right;@GetRowStyle(context)">@FormatVND(context.AverageOrderValue)</MudTd>
<MudTd Style="text-align:right;@GetRowStyle(context)">
<MudChip T="string"
Size="Size.Small"
Color="@(context.CompletionRate >= 90 ? Color.Success : context.CompletionRate >= 70 ? Color.Warning : Color.Error)"
Variant="Variant.Filled">
@context.CompletionRate.ToString("F1")%
</MudChip>
</MudTd>
<MudTd Style="text-align:right;@GetRowStyle(context)">@context.AverageHandlingTimeMinutes.ToString("F1") phut</MudTd>
</RowTemplate>
<FooterContent>
<MudTh Style="font-weight:700;color:var(--admin-text-secondary);">Trung binh cua hang</MudTh>
<MudTh Style="text-align:right;font-weight:700;">@_data.ShopAverage.OrdersHandled</MudTh>
<MudTh Style="text-align:right;font-weight:700;color:var(--admin-orange-primary);">@FormatVND(_data.ShopAverage.TotalRevenue)</MudTh>
<MudTh Style="text-align:right;font-weight:700;">@FormatVND(_data.ShopAverage.AverageOrderValue)</MudTh>
<MudTh Style="text-align:right;font-weight:700;">@_data.ShopAverage.CompletionRate.ToString("F1")%</MudTh>
<MudTh Style="text-align:right;font-weight:700;">@_data.ShopAverage.AverageHandlingTimeMinutes.ToString("F1") phut</MudTh>
</FooterContent>
</MudTable>
</div>
</div>
}
</div>
@code {
// EN: Date range for the report / VI: Khoang thoi gian cho bao cao
private DateRange _dateRange = new(DateTime.Today.AddDays(-30), DateTime.Today);
private bool _loading = false;
private PosDataService.StaffPerformanceInfo? _data;
private PosDataService.StaffMetricsInfo? _bestPerformer;
// EN: Chart data / VI: Du lieu bieu do
private List<ChartSeries> _revenueChartSeries = new();
private string[] _revenueChartLabels = Array.Empty<string>();
private ChartOptions _barOptions = new()
{
ChartPalette = new[] { "#FF5C00", "#22C55E" },
YAxisTicks = 5
};
/// <summary>
/// EN: Get the current shop ID from query string context.
/// VI: Lay shop ID hien tai tu query string context.
/// </summary>
private Guid? GetCurrentShopId()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
if (Guid.TryParse(query["shopId"], out var qsShopId))
return qsShopId;
return null;
}
/// <summary>
/// EN: Load staff performance data.
/// VI: Tai du lieu hieu suat nhan vien.
/// </summary>
private async Task LoadDataAsync()
{
var shopId = GetCurrentShopId();
if (!shopId.HasValue)
{
Snackbar.Add("Vui long chon shop truoc khi xem bao cao.", Severity.Warning);
return;
}
if (_dateRange.Start == null || _dateRange.End == null)
{
Snackbar.Add("Vui long chon khoang thoi gian.", Severity.Warning);
return;
}
_loading = true;
_data = null;
_bestPerformer = null;
StateHasChanged();
try
{
_data = await DataService.GetStaffPerformanceAsync(
shopId.Value,
_dateRange.Start.Value,
_dateRange.End.Value);
if (_data != null)
{
_bestPerformer = _data.Staff.OrderByDescending(s => s.TotalRevenue).FirstOrDefault();
BuildChartData();
Snackbar.Add("Da tai hieu suat nhan vien thanh cong.", Severity.Success);
}
else
{
Snackbar.Add("Khong the tai du lieu. Vui long thu lai.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
StateHasChanged();
}
}
/// <summary>
/// EN: Build chart data from staff performance data.
/// VI: Xay dung du lieu bieu do tu du lieu hieu suat nhan vien.
/// </summary>
private void BuildChartData()
{
if (_data == null || !_data.Staff.Any())
{
_revenueChartLabels = Array.Empty<string>();
_revenueChartSeries = new();
return;
}
var orderedStaff = _data.Staff.OrderByDescending(s => s.TotalRevenue).Take(10).ToList();
_revenueChartLabels = orderedStaff.Select(s => s.StaffName).ToArray();
_revenueChartSeries = new List<ChartSeries>
{
new ChartSeries { Name = "Doanh thu", Data = orderedStaff.Select(s => (double)s.TotalRevenue).ToArray() },
new ChartSeries { Name = "Don hang", Data = orderedStaff.Select(s => (double)s.OrdersHandled).ToArray() }
};
}
/// <summary>
/// EN: Get row style color-coded by completion rate: green (>=90%), yellow (70-89%), red (<70%).
/// VI: Lay style dong theo ma mau ty le hoan thanh: xanh (>=90%), vang (70-89%), do (<70%).
/// </summary>
private string GetRowStyle(PosDataService.StaffMetricsInfo staff)
{
if (staff.CompletionRate >= 90)
return "background:rgba(34,197,94,0.06);";
if (staff.CompletionRate >= 70)
return "background:rgba(245,158,11,0.06);";
return "background:rgba(239,68,68,0.06);";
}
/// <summary>
/// EN: Export staff performance data to CSV and trigger browser download.
/// VI: Xuat du lieu hieu suat nhan vien sang CSV va kich hoat tai xuong tren trinh duyet.
/// </summary>
private async Task ExportCsvAsync()
{
if (_data == null || !_data.Staff.Any())
{
Snackbar.Add("Khong co du lieu de xuat.", Severity.Warning);
return;
}
try
{
var sb = new StringBuilder();
sb.AppendLine("Nhan vien,Don xu ly,Doanh thu,GT TB / don,Hoan thanh,Da huy,Ty le HT (%),TG xu ly TB (phut)");
foreach (var s in _data.Staff)
{
sb.AppendLine($"\"{s.StaffName}\",{s.OrdersHandled},{s.TotalRevenue:F0},{s.AverageOrderValue:F0},{s.CompletedOrders},{s.CancelledOrders},{s.CompletionRate:F1},{s.AverageHandlingTimeMinutes:F1}");
}
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
var base64 = Convert.ToBase64String(bytes);
var fileName = $"hieu-suat-nhan-vien_{_dateRange.Start:yyyyMMdd}_{_dateRange.End:yyyyMMdd}.csv";
// EN: Use JS interop to trigger download / VI: Dung JS interop de kich hoat tai xuong
await JS.InvokeVoidAsync("eval", $@"
var link = document.createElement('a');
link.href = 'data:text/csv;base64,{base64}';
link.download = '{fileName}';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
");
Snackbar.Add($"Da xuat file {fileName}.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Loi khi xuat CSV: {ex.Message}", Severity.Error);
}
}
private static string FormatVND(decimal val) => val.ToString("N0") + " d";
}

View File

@@ -0,0 +1,270 @@
@page "/admin/shop/{ShopId:guid}/qr-codes"
@layout WebClientTpos.Client.Layout.AdminLayout
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject NavigationManager Nav
@inject IJSRuntime JS
<div class="admin-page-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;">
<div>
<h1 style="font-size:22px;font-weight:700;margin:0;color:var(--admin-text-primary);">
<i data-lucide="qr-code" style="width:22px;height:22px;vertical-align:middle;margin-right:8px;color:var(--admin-orange-primary);"></i>Mã QR Thực Đơn
</h1>
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:4px 0 0;">Tạo và in mã QR cho từng bàn/phòng để khách hàng quét xem menu</p>
</div>
<div style="display:flex;gap:8px;">
<a href="/admin/shop/@ShopId/tables" 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);text-decoration:none;font-size:13px;font-weight:600;">
<i data-lucide="arrow-left" style="width:14px;height:14px;"></i>Quản lý bàn
</a>
@if (_tables.Any())
{
<button class="admin-btn-primary" @onclick="BatchGenerateQr" disabled="@_batchGenerating" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="zap" style="width:14px;height:14px;"></i>@(_batchGenerating ? "Đang tạo..." : "Tạo QR tất cả")
</button>
<button class="admin-btn-primary" @onclick="PrintAllQr" style="display:inline-flex;align-items:center;gap:6px;background:#8B5CF6;">
<i data-lucide="printer" style="width:14px;height:14px;"></i>In mã QR
</button>
}
</div>
</div>
@if (_loading)
{
<div style="text-align:center;padding:60px;">
<div style="width:40px;height:40px;border:3px solid var(--admin-border-subtle);border-top-color:var(--admin-orange-primary);border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 12px;"></div>
<p style="color:var(--admin-text-tertiary);font-size:14px;">Đang tải danh sách bàn...</p>
</div>
}
else if (!_tables.Any())
{
<div style="text-align:center;padding:80px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(245,158,11,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="grid-3x3" style="width:36px;height:36px;color:var(--admin-orange-primary);"></i>
</div>
<h2 style="font-size:20px;font-weight:700;color:var(--admin-text-primary);margin:0 0 8px;">Chưa có bàn nào</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Tạo bàn trước rồi quay lại đây để tạo mã QR</p>
<a href="/admin/shop/@ShopId/tables" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm bàn
</a>
</div>
}
else
{
@* ═══ STATS ═══ *@
<div style="display:flex;gap:16px;margin-bottom:20px;">
<div style="padding:12px 20px;border-radius:10px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);">
<span style="font-size:13px;color:var(--admin-text-tertiary);">Tổng bàn: </span>
<span style="font-size:15px;font-weight:700;color:var(--admin-text-primary);">@_tables.Count</span>
</div>
<div style="padding:12px 20px;border-radius:10px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);">
<span style="font-size:13px;color:var(--admin-text-tertiary);">Có QR: </span>
<span style="font-size:15px;font-weight:700;color:#22C55E;">@_tables.Count(t => !string.IsNullOrEmpty(t.QrToken))</span>
</div>
<div style="padding:12px 20px;border-radius:10px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);">
<span style="font-size:13px;color:var(--admin-text-tertiary);">Chưa có QR: </span>
<span style="font-size:15px;font-weight:700;color:#F59E0B;">@_tables.Count(t => string.IsNullOrEmpty(t.QrToken))</span>
</div>
</div>
@if (!string.IsNullOrEmpty(_batchMessage))
{
<div style="margin-bottom:16px;padding:12px 16px;border-radius:10px;background:@(_batchSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");border:1px solid @(_batchSuccess ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)");font-size:13px;color:@(_batchSuccess ? "#22C55E" : "#EF4444");">
@_batchMessage
</div>
}
@* ═══ QR CARDS GRID ═══ *@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;">
@foreach (var table in _tables)
{
var hasQr = !string.IsNullOrEmpty(table.QrToken);
var menuUrl = $"{Nav.BaseUri}menu/{ShopId}/{table.Id}";
var qrUrl = hasQr ? $"{Nav.BaseUri}table/{table.QrToken}" : null;
var displayUrl = qrUrl ?? menuUrl;
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-radius:16px;overflow:hidden;">
@* Table header *@
<div style="padding:16px 16px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--admin-border-subtle);">
<div>
<div style="font-size:16px;font-weight:700;color:var(--admin-text-primary);">Bàn @table.TableNumber</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@(table.Zone ?? "Chung") • @table.Capacity chỗ</div>
</div>
@if (hasQr)
{
<span style="font-size:11px;font-weight:600;color:#22C55E;background:rgba(34,197,94,0.1);padding:4px 10px;border-radius:6px;">
<span style="width:6px;height:6px;border-radius:50%;background:#22C55E;display:inline-block;margin-right:4px;"></span>Có QR
</span>
}
else
{
<span style="font-size:11px;font-weight:600;color:#F59E0B;background:rgba(245,158,11,0.1);padding:4px 10px;border-radius:6px;">Chưa có QR</span>
}
</div>
@* QR Code *@
<div style="padding:20px;text-align:center;">
@if (hasQr)
{
<div style="background:white;border-radius:12px;padding:16px;display:inline-block;margin-bottom:12px;">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=@(Uri.EscapeDataString(displayUrl))" alt="QR Code" style="width:180px;height:180px;" />
</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);word-break:break-all;padding:0 8px;margin-bottom:12px;">@displayUrl</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button @onclick='() => CopyUrl(displayUrl)' style="padding:6px 14px;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:4px;">
<i data-lucide="copy" style="width:12px;height:12px;"></i>Sao chép
</button>
<button @onclick='() => DownloadQr(table.TableNumber, displayUrl)' style="padding:6px 14px;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:4px;">
<i data-lucide="download" style="width:12px;height:12px;"></i>Tải về
</button>
</div>
}
else
{
<div style="padding:20px;color:var(--admin-text-tertiary);">
<i data-lucide="qr-code" style="width:48px;height:48px;opacity:0.3;margin-bottom:8px;"></i>
<p style="font-size:13px;margin:0 0 12px;">Chưa có mã QR</p>
<button class="admin-btn-primary" @onclick="() => GenerateSingleQr(table)" style="font-size:12px;padding:6px 16px;">
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Tạo QR
</button>
</div>
}
</div>
</div>
}
</div>
}
@if (_copied)
{
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#059669;color:white;padding:10px 24px;border-radius:10px;font-size:13px;font-weight:600;z-index:300;box-shadow:0 4px 12px rgba(0,0,0,0.15);">
Đã sao chép URL!
</div>
}
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.TableInfo> _tables = new();
private bool _loading = true;
private bool _batchGenerating;
private string? _batchMessage;
private bool _batchSuccess;
private bool _copied;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_tables = await DataService.GetTablesAsync(ShopId);
_loading = false;
}
private async Task GenerateSingleQr(PosDataService.TableInfo table)
{
try
{
var token = await DataService.GenerateTableQrTokenAsync(table.Id);
if (token != null)
{
_tables = await DataService.GetTablesAsync(ShopId);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"QR generation failed for table {table.TableNumber}: {ex.Message}");
}
}
private async Task BatchGenerateQr()
{
_batchGenerating = true;
_batchMessage = null;
var generated = 0;
var failed = 0;
foreach (var table in _tables.Where(t => string.IsNullOrEmpty(t.QrToken)))
{
try
{
var token = await DataService.GenerateTableQrTokenAsync(table.Id);
if (token != null) generated++;
else failed++;
}
catch { failed++; }
}
_tables = await DataService.GetTablesAsync(ShopId);
if (failed == 0)
{
_batchMessage = $"Đã tạo mã QR cho {generated} bàn thành công!";
_batchSuccess = true;
}
else
{
_batchMessage = $"Tạo xong: {generated} thành công, {failed} thất bại.";
_batchSuccess = false;
}
_batchGenerating = false;
}
private async Task CopyUrl(string url)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", url);
_copied = true;
StateHasChanged();
await Task.Delay(2000);
_copied = false;
StateHasChanged();
}
catch { }
}
private async Task DownloadQr(string tableNumber, string url)
{
try
{
var qrImageUrl = $"https://api.qrserver.com/v1/create-qr-code/?size=400x400&format=png&data={Uri.EscapeDataString(url)}";
await JS.InvokeVoidAsync("eval",
$"var a=document.createElement('a');a.href='{qrImageUrl}';a.download='qr-ban-{tableNumber}.png';a.target='_blank';document.body.appendChild(a);a.click();document.body.removeChild(a);");
}
catch { }
}
private async Task PrintAllQr()
{
var tablesWithQr = _tables.Where(t => !string.IsNullOrEmpty(t.QrToken)).ToList();
if (!tablesWithQr.Any()) return;
var cards = string.Join("", tablesWithQr.Select(t =>
{
var qrUrl = $"{Nav.BaseUri}table/{t.QrToken}";
return $@"
<div style=""width:280px;padding:24px;border:1px solid #E5E7EB;border-radius:12px;text-align:center;page-break-inside:avoid;"">
<h3 style=""font-size:18px;font-weight:700;margin:0 0 4px;"">Ban {t.TableNumber}</h3>
<p style=""font-size:12px;color:#6B7280;margin:0 0 12px;"">{t.Zone ?? "Chung"} - {t.Capacity} cho</p>
<img src=""https://api.qrserver.com/v1/create-qr-code/?size=240x240&data={Uri.EscapeDataString(qrUrl)}"" style=""width:200px;height:200px;"" />
<p style=""font-size:10px;color:#9CA3AF;margin:8px 0 0;word-break:break-all;"">{qrUrl}</p>
</div>";
}));
var html = $@"
<html>
<head><title>Ma QR - Cua hang</title></head>
<body style=""font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:20px;"">
<h1 style=""text-align:center;font-size:22px;margin-bottom:24px;"">Ma QR Thuc Don</h1>
<div style=""display:flex;flex-wrap:wrap;gap:16px;justify-content:center;"">
{cards}
</div>
</body>
</html>";
await JS.InvokeVoidAsync("eval",
$"var w=window.open('','_blank','width=900,height=700');w.document.write(`{html.Replace("`", "\\`")}`);w.document.close();setTimeout(function(){{w.print();}},500);");
}
}

View File

@@ -23,6 +23,9 @@
<i data-lucide="map" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;"></i>Sơ đồ
</button>
</div>
<a href="/admin/shop/@ShopId/qr-codes" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid rgba(139,92,246,0.4);background:rgba(139,92,246,0.08);color:#8B5CF6;text-decoration:none;font-size:12px;font-weight:600;">
<i data-lucide="qr-code" style="width:14px;height:14px;"></i>Mã QR hàng loạt
</a>
<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>
@@ -148,6 +151,9 @@ else if (SubSection == "rooms")
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang hát: @_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>
<a href="/admin/shop/@ShopId/qr-codes" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid rgba(139,92,246,0.4);background:rgba(139,92,246,0.08);color:#8B5CF6;text-decoration:none;font-size:12px;font-weight:600;">
<i data-lucide="qr-code" style="width:14px;height:14px;"></i>Mã QR hàng loạt
</a>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 8; _newTableZone = ""; _newHourlyRate = 0; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm phòng
</button>

View File

@@ -0,0 +1,422 @@
@page "/menu/{ShopId:guid}"
@page "/menu/{ShopId:guid}/{TableId:guid}"
@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:#FF5C00;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<p style="font-size:14px;">Đang tải thực đơn...</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;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<h2 style="font-size:20px;font-weight:700;color:#1F2937;margin:0 0 8px;">Không tìm thấy thực đơn</h2>
<p style="color:#6B7280;font-size:14px;">@_error</p>
</div>
</div>
}
else
{
@* ═══ STICKY HEADER ═══ *@
<div style="background:white;box-shadow:0 1px 3px rgba(0,0,0,0.08);position:sticky;top:0;z-index:50;">
<div style="max-width:640px;margin:0 auto;padding:12px 16px;">
<div style="display:flex;align-items:center;gap:12px;">
@if (!string.IsNullOrEmpty(_shop?.LogoUrl))
{
<img src="@_shop.LogoUrl" alt="@_shop.Name" style="width:40px;height:40px;border-radius:10px;object-fit:cover;" />
}
else
{
<div style="width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#FF5C00,#FF8A3D);display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2l2 7h14l2-7"/><path d="M3 9v11a2 2 0 002 2h14a2 2 0 002-2V9"/></svg>
</div>
}
<div style="flex:1;min-width:0;">
<h1 style="font-size:16px;font-weight:700;color:#1F2937;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">@(_shop?.Name ?? "Thực đơn")</h1>
@if (!string.IsNullOrEmpty(_shop?.Address))
{
<p style="font-size:12px;color:#9CA3AF;margin:2px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">@_shop.Address</p>
}
</div>
</div>
@* ═══ SEARCH BAR ═══ *@
<div style="margin-top:10px;position:relative;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);pointer-events:none;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" @bind="_searchQuery" @bind:event="oninput" placeholder="Tìm kiếm món ăn..."
style="width:100%;padding:10px 12px 10px 36px;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB;font-size:14px;color:#1F2937;outline:none;box-sizing:border-box;" />
@if (!string.IsNullOrEmpty(_searchQuery))
{
<button @onclick='() => _searchQuery = ""' style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:#9CA3AF;font-size:16px;line-height:1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
}
</div>
</div>
</div>
<div style="max-width:640px;margin:0 auto;padding:12px 16px 120px;">
@* ═══ CATEGORY TABS ═══ *@
@if (_categories.Count > 1 && string.IsNullOrEmpty(_searchQuery))
{
<div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:12px;margin-bottom:8px;-webkit-overflow-scrolling:touch;scrollbar-width:none;">
<button @onclick='() => _selectedCategoryId = null'
style="white-space:nowrap;padding:8px 16px;border-radius:20px;border:none;font-size:13px;font-weight:600;cursor:pointer;flex-shrink:0;transition:all .2s;@(_selectedCategoryId == null ? "background:#FF5C00;color:white;" : "background:#F3F4F6;color:#6B7280;")">
Tất cả
</button>
@foreach (var cat in _categories)
{
var catId = cat.Id;
<button @onclick='() => _selectedCategoryId = catId'
style="white-space:nowrap;padding:8px 16px;border-radius:20px;border:none;font-size:13px;font-weight:600;cursor:pointer;flex-shrink:0;transition:all .2s;@(_selectedCategoryId == catId ? "background:#FF5C00;color:white;" : "background:#F3F4F6;color:#6B7280;")">
@cat.Name (@cat.Items.Count)
</button>
}
</div>
}
@* ═══ MENU ITEMS ═══ *@
@if (!FilteredItems.Any())
{
<div style="text-align:center;padding:60px 20px;color:#9CA3AF;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#D1D5DB" stroke-width="1.5" style="margin:0 auto 12px;display:block;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p style="font-size:15px;font-weight:600;color:#6B7280;margin:0 0 4px;">Không tìm thấy món nào</p>
<p style="font-size:13px;margin:0;">Thử tìm kiếm với từ khóa khác</p>
</div>
}
else if (!string.IsNullOrEmpty(_searchQuery))
{
@* ═══ SEARCH RESULTS — FLAT LIST ═══ *@
<div style="display:grid;grid-template-columns:1fr;gap:12px;">
@foreach (var item in FilteredItems)
{
@RenderMenuItem(item)
}
</div>
}
else
{
@* ═══ CATEGORIZED VIEW ═══ *@
@foreach (var category in DisplayCategories)
{
<div style="margin-bottom:20px;">
<h2 style="font-size:16px;font-weight:700;color:#1F2937;margin:0 0 12px;padding-bottom:8px;border-bottom:1px solid #F3F4F6;">
@category.Name
<span style="font-size:12px;font-weight:400;color:#9CA3AF;margin-left:6px;">@category.Items.Count món</span>
</h2>
<div style="display:grid;grid-template-columns:1fr;gap:12px;">
@foreach (var item in category.Items)
{
@RenderMenuItem(item)
}
</div>
</div>
}
}
</div>
@* ═══ STICKY BOTTOM BAR ═══ *@
@if (_cart.Any())
{
<div style="position:fixed;bottom:0;left:0;right:0;z-index:100;">
<div style="max-width:640px;margin:0 auto;padding:0 16px 16px;">
<button @onclick='() => _showCart = true'
style="width:100%;padding:14px 20px;border-radius:14px;border:none;background:#FF5C00;color:white;cursor:pointer;display:flex;align-items:center;justify-content:space-between;box-shadow:0 4px 20px rgba(255,92,0,0.35);box-sizing:border-box;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:28px;height:28px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;">
@_cart.Sum(c => c.Qty)
</div>
<span style="font-size:15px;font-weight:600;">Giỏ hàng</span>
</div>
<span style="font-size:16px;font-weight:700;">@FormatVND(_cart.Sum(c => c.Price * c.Qty))</span>
</button>
</div>
</div>
}
@* ═══ CART PANEL ═══ *@
@if (_showCart && _cart.Any())
{
<div @onclick='() => _showCart = false' style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:200;"></div>
<div style="position:fixed;bottom:0;left:0;right:0;background:white;box-shadow:0 -4px 24px rgba(0,0,0,0.12);border-radius:20px 20px 0 0;z-index:201;max-height:70vh;overflow-y:auto;">
<div style="max-width:640px;margin:0 auto;padding:20px 16px;">
<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="width:32px;height:32px;border-radius:8px;background:#F3F4F6;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6B7280" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@foreach (var item in _cart)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #F3F4F6;">
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:600;color:#1F2937;">@item.Name</div>
<div style="font-size:13px;color:#FF5C00;font-weight:600;margin-top:2px;">@FormatVND(item.Price)</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-left:12px;">
<button @onclick='() => UpdateCartQty(item.ProductId, -1)' style="width:30px;height:30px;border-radius:8px;border:1px 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:15px;font-weight:700;color:#1F2937;min-width:20px;text-align:center;">@item.Qty</span>
<button @onclick='() => UpdateCartQty(item.ProductId, 1)' style="width:30px;height:30px;border-radius:8px;border:none;background:#FF5C00;color:white;font-size:16px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;">+</button>
</div>
</div>
}
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:16px;">
<span style="font-size:16px;font-weight:700;color:#1F2937;">Tổng cộng</span>
<span style="font-size:20px;font-weight:700;color:#FF5C00;">@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:#FF5C00;color:white;font-size:16px;font-weight:700;cursor:pointer;margin-top:16px;opacity:@(_ordering ? "0.7" : "1");">
@(_ordering ? "Đang gửi..." : "Đặt món")
</button>
@if (_orderMessage != null)
{
<div style="margin-top:10px;text-align:center;font-size:14px;padding:10px;border-radius:8px;background:@(_orderSuccess ? "#F0FDF4" : "#FEF2F2");color:@(_orderSuccess ? "#059669" : "#DC2626");">
@_orderMessage
</div>
}
</div>
</div>
}
@* ═══ ORDER SUCCESS TOAST ═══ *@
@if (_showOrderSuccess)
{
<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:300;box-shadow:0 4px 16px rgba(0,0,0,0.15);display:flex;align-items:center;gap:8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Đặt món thành công!
</div>
}
}
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
/* Hide scrollbar for category tabs */
div::-webkit-scrollbar { display: none; }
</style>
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public Guid? TableId { get; set; }
private bool _loading = true;
private string? _error;
private PosDataService.PublicShopInfo? _shop;
private List<PosDataService.PublicMenuCategory> _categories = new();
// Search & filter
private string _searchQuery = "";
private Guid? _selectedCategoryId;
// Cart
private readonly List<CartItem> _cart = new();
private bool _showCart;
private bool _ordering;
private string? _orderMessage;
private bool _orderSuccess;
private bool _showOrderSuccess;
// EN: Filtered items based on search query and selected category.
// VI: Lọc món dựa trên từ khóa tìm kiếm và danh mục đã chọn.
private IEnumerable<PosDataService.PublicMenuItem> FilteredItems
{
get
{
var allItems = _categories.SelectMany(c => c.Items);
if (!string.IsNullOrWhiteSpace(_searchQuery))
{
var query = _searchQuery.Trim().ToLowerInvariant();
return allItems.Where(i =>
i.Name.ToLowerInvariant().Contains(query) ||
(i.Description?.ToLowerInvariant().Contains(query) ?? false));
}
if (_selectedCategoryId != null)
{
var cat = _categories.FirstOrDefault(c => c.Id == _selectedCategoryId);
return cat?.Items ?? Enumerable.Empty<PosDataService.PublicMenuItem>();
}
return allItems;
}
}
// EN: Categories to display (respecting selected category filter).
// VI: Danh mục để hiển thị (tôn trọng bộ lọc danh mục đã chọn).
private IEnumerable<PosDataService.PublicMenuCategory> DisplayCategories =>
_selectedCategoryId != null
? _categories.Where(c => c.Id == _selectedCategoryId)
: _categories;
protected override async Task OnInitializedAsync()
{
try
{
// EN: Fetch shop info and menu in parallel
// VI: Lấy thông tin shop và menu song song
var shopTask = DataService.GetPublicShopInfoAsync(ShopId);
var menuTask = DataService.GetPublicMenuAsync(ShopId);
await Task.WhenAll(shopTask, menuTask);
_shop = await shopTask;
_categories = await menuTask;
if (_shop == null && !_categories.Any())
{
_error = "Cửa hàng không tồn tại hoặc chưa có thực đơn.";
}
}
catch
{
_error = "Không thể tải thực đơn. Vui lòng thử lại.";
}
_loading = false;
}
private RenderFragment RenderMenuItem(PosDataService.PublicMenuItem item) => __builder =>
{
var inCart = _cart.FirstOrDefault(c => c.ProductId == item.Id);
var isUnavailable = !item.IsAvailable;
<div style="background:white;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);border:1px solid #F3F4F6;display:flex;gap:12px;padding:12px;@(isUnavailable ? "opacity:0.5;" : "")">
@* Image or placeholder *@
@if (!string.IsNullOrEmpty(item.ImageUrl))
{
<div style="width:80px;height:80px;border-radius:10px;overflow:hidden;flex-shrink:0;position:relative;">
<img src="@item.ImageUrl" alt="@item.Name" style="width:100%;height:100%;object-fit:cover;" />
@if (isUnavailable)
{
<div style="position:absolute;top:4px;right:4px;background:#EF4444;color:white;font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;">Hết</div>
}
</div>
}
else
{
<div style="width:80px;height:80px;border-radius:10px;background:#F9FAFB;display:flex;align-items:center;justify-content:center;flex-shrink:0;position:relative;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#D1D5DB" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
@if (isUnavailable)
{
<div style="position:absolute;top:4px;right:4px;background:#EF4444;color:white;font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;">Hết</div>
}
</div>
}
@* Content *@
<div style="flex:1;min-width:0;display:flex;flex-direction:column;justify-content:space-between;">
<div>
<div style="font-size:14px;font-weight:600;color:#1F2937;line-height:1.3;margin-bottom:2px;">@item.Name</div>
@if (!string.IsNullOrEmpty(item.Description))
{
<div style="font-size:12px;color:#9CA3AF;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@item.Description</div>
}
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:6px;">
<span style="font-size:15px;font-weight:700;color:#FF5C00;">@FormatVND(item.Price)</span>
@if (!isUnavailable)
{
@if (inCart != null)
{
<div style="display:flex;align-items:center;gap:8px;">
<button @onclick='() => UpdateCartQty(item.Id, -1)' style="width:28px;height:28px;border-radius:8px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:14px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;">-</button>
<span style="font-size:14px;font-weight:700;color:#1F2937;min-width:16px;text-align:center;">@inCart.Qty</span>
<button @onclick='() => UpdateCartQty(item.Id, 1)' style="width:28px;height:28px;border-radius:8px;border:none;background:#FF5C00;color:white;font-size:14px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;">+</button>
</div>
}
else
{
<button @onclick='() => AddToCart(item)' style="padding:6px 14px;border-radius:8px;border:none;background:#FFF7ED;color:#FF5C00;font-weight:600;font-size:13px;cursor:pointer;">
+ Thêm
</button>
}
}
else
{
<span style="font-size:12px;font-weight:600;color:#EF4444;background:#FEF2F2;padding:4px 10px;border-radius:6px;">Hết hàng</span>
}
</div>
</div>
</div>
};
private void AddToCart(PosDataService.PublicMenuItem item)
{
var existing = _cart.FirstOrDefault(c => c.ProductId == item.Id);
if (existing != null)
existing.Qty++;
else
_cart.Add(new CartItem(item.Id, item.Name, item.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)
{
_orderSuccess = true;
_orderMessage = "Đặt món thành công! Món sẽ được phục vụ sớm nhất.";
_cart.Clear();
_showCart = false;
_showOrderSuccess = true;
StateHasChanged();
await Task.Delay(3000);
_showOrderSuccess = false;
StateHasChanged();
}
else
{
_orderSuccess = false;
_orderMessage = "Không thể đặt món. Vui lòng thử lại.";
}
}
catch
{
_orderSuccess = false;
_orderMessage = "Lỗi khi đặt món. Vui lòng thử lại.";
}
_ordering = false;
}
private static string FormatVND(decimal amount)
{
// EN: Format as Vietnamese dong: "45.000d" with dot thousands separator.
// VI: Định dạng đồng Việt Nam: "45.000d" với dấu chấm ngăn cách hàng nghìn.
return $"{amount:N0}".Replace(",", ".") + "₫";
}
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

@@ -932,4 +932,557 @@
}
.pos-settings-input:focus {
border-color: var(--pos-orange-primary, #ff5c00);
}
/* ═════════════════════════════════════════════════════════════════════════
12. POS RESPONSIVE — Mobile hamburger, sidebar, order drawer
EN: Responsive elements for tablet/mobile POS usage.
VI: Các phần tử responsive cho POS trên tablet/mobile.
═════════════════════════════════════════════════════════════════════════ */
/* EN: Mobile hamburger toggle — hidden on desktop / VI: Nút hamburger — ẩn trên desktop */
.pos-mobile-toggle {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--pos-text-primary);
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.pos-mobile-toggle:hover {
background-color: var(--pos-bg-interactive);
}
/* EN: Order panel toggle — hidden on desktop / VI: Nút toggle panel đơn hàng — ẩn trên desktop */
.pos-order-toggle {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--pos-text-primary);
cursor: pointer;
transition: background 0.2s ease;
position: relative;
flex-shrink: 0;
}
.pos-order-toggle:hover {
background-color: var(--pos-bg-interactive);
}
.pos-order-toggle__badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--pos-orange-primary);
color: #FFFFFF;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* EN: Admin button — always visible / VI: Nút admin — luôn hiện */
.pos-admin-btn {
flex-shrink: 0;
}
/* EN: Status bar time / VI: Thời gian thanh trạng thái */
.pos-status-bar__time {
font-size: 13px;
color: var(--pos-text-secondary);
}
/* EN: Sidebar navigation — hidden on desktop (desktop uses page-level nav)
VI: Sidebar điều hướng — ẩn trên desktop (desktop dùng nav cấp trang) */
.pos-sidebar {
display: none;
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
background-color: var(--pos-bg-elevated);
border-right: 1px solid var(--pos-border-subtle);
z-index: 200;
flex-direction: column;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pos-sidebar--open {
left: 0;
}
.pos-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--pos-border-subtle);
}
.pos-sidebar__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--pos-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pos-sidebar__close:hover {
background-color: var(--pos-bg-interactive);
}
.pos-sidebar__nav {
flex: 1;
padding: 8px;
overflow-y: auto;
}
.pos-sidebar__footer {
padding: 8px;
border-top: 1px solid var(--pos-border-subtle);
}
.pos-sidebar__link {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 12px;
border-radius: var(--pos-radius);
color: var(--pos-text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.pos-sidebar__link:hover {
background-color: var(--pos-bg-interactive);
color: var(--pos-text-primary);
}
/* EN: Sidebar overlay — click to close / VI: Overlay sidebar — click để đóng */
.pos-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 190;
}
/* EN: Desktop-only cart panel modifier — shown inline on desktop, hidden on tablet/mobile
VI: Modifier cart panel chi desktop — hiện inline trên desktop, ẩn trên tablet/mobile */
.pos-cart-panel--desktop {
display: flex;
}
/* EN: Page content wrapper — enables flex child behavior
VI: Wrapper nội dung trang — cho phép behavior flex child */
.pos-page-content {
flex: 1;
display: flex;
overflow: hidden;
min-width: 0;
}
/* EN: Order panel drawer — slides from right, hidden on desktop
VI: Drawer panel đơn hàng — trượt từ phải, ẩn trên desktop */
.pos-order-drawer {
display: none;
position: fixed;
top: 0;
right: -400px;
width: 380px;
max-width: 100vw;
height: 100vh;
background-color: var(--pos-bg-elevated);
border-left: 1px solid var(--pos-border-subtle);
z-index: 200;
flex-direction: column;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pos-order-drawer--open {
right: 0;
}
.pos-order-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--pos-border-subtle);
flex-shrink: 0;
}
.pos-order-drawer__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--pos-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pos-order-drawer__close:hover {
background-color: var(--pos-bg-interactive);
}
.pos-order-drawer__content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* EN: Order panel overlay — click to close / VI: Overlay panel đơn hàng — click để đóng */
.pos-order-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 190;
}
/* ═════════════════════════════════════════════════════════════════════════
13. RESPONSIVE BREAKPOINTS
EN: Tablet (max-width: 1024px) and Mobile (max-width: 600px) adaptations.
VI: Tablet (max-width: 1024px) và Mobile (max-width: 600px) tùy chỉnh.
═════════════════════════════════════════════════════════════════════════ */
/* ── TABLET: <= 1024px ── */
@media (max-width: 1024px) {
/* EN: Show hamburger and order toggle / VI: Hiện hamburger và toggle đơn hàng */
.pos-mobile-toggle {
display: flex;
}
.pos-order-toggle {
display: flex;
}
/* EN: Enable sidebar as overlay / VI: Bật sidebar dạng overlay */
.pos-sidebar {
display: flex;
}
.pos-sidebar-overlay {
display: block;
}
/* EN: Enable order drawer as overlay / VI: Bật order drawer dạng overlay */
.pos-order-drawer {
display: flex;
}
.pos-order-overlay {
display: block;
}
/* EN: Cart panel becomes hidden on tablet — use drawer instead
VI: Cart panel ẩn trên tablet — dùng drawer thay thế */
.pos-cart-panel,
.pos-cart-panel--desktop {
display: none !important;
}
/* EN: Product panel takes full width / VI: Panel sản phẩm chiếm hết chiều rộng */
.pos-product-panel {
flex: 1;
width: 100%;
}
/* EN: Content area fills available space / VI: Vùng nội dung chiếm hết không gian */
.pos-content-area {
flex-direction: column;
}
/* EN: Adjust product grid for tablet / VI: Điều chỉnh lưới sản phẩm cho tablet */
.pos-product-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
padding: 12px;
}
/* EN: Dashboard stats 2 columns on tablet / VI: Thống kê dashboard 2 cột trên tablet */
.pos-dashboard__stats {
grid-template-columns: repeat(2, 1fr);
}
/* EN: Dashboard grid single column / VI: Lưới dashboard 1 cột */
.pos-dashboard__grid {
grid-template-columns: 1fr;
}
/* EN: History toolbar wrap / VI: Thanh công cụ lịch sử wrap */
.pos-history__toolbar {
flex-wrap: wrap;
}
/* EN: Touch-friendly category tabs / VI: Tab danh mục thân thiện cảm ứng */
.pos-category-tab {
padding: 10px 18px;
font-size: 14px;
min-height: 44px;
}
/* EN: Touch-friendly buttons / VI: Nút thân thiện cảm ứng */
.pos-btn-checkout {
min-height: 52px;
font-size: 16px;
}
/* EN: Touch-friendly cart items / VI: Mục giỏ hàng thân thiện cảm ứng */
.pos-cart-item {
padding: 12px;
min-height: 48px;
}
.pos-cart-item__qty button {
width: 36px;
height: 36px;
}
/* EN: Payment methods larger touch targets / VI: Phương thức thanh toán vùng chạm lớn hơn */
.pos-payment-method-btn {
padding: 24px 16px;
min-height: 80px;
}
.pos-payment-quick-btn {
padding: 14px 10px;
min-height: 48px;
}
}
/* ── MOBILE: <= 600px ── */
@media (max-width: 600px) {
/* EN: Compact status bar / VI: Thanh trạng thái thu gọn */
.pos-status-bar {
height: 44px;
padding: 0 8px;
}
.pos-status-bar__logo {
font-size: 13px;
}
.pos-status-bar__store {
font-size: 11px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pos-status-bar__time {
display: none;
}
.pos-status-bar__indicator span:last-child {
display: none;
}
.pos-status-bar__left {
gap: 8px;
}
.pos-status-bar__right {
gap: 4px;
}
/* EN: Adjust status bar height token / VI: Điều chỉnh token chiều cao thanh trạng thái */
.pos-layout {
--pos-status-bar-height: 44px;
}
/* EN: Order drawer full width on mobile / VI: Drawer đơn hàng full chiều rộng trên mobile */
.pos-order-drawer {
width: 100vw;
right: -100vw;
}
/* EN: Product grid single/double column / VI: Lưới sản phẩm 1-2 cột */
.pos-product-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
padding: 8px;
}
/* EN: Smaller product cards / VI: Card sản phẩm nhỏ hơn */
.pos-product-card {
padding: 10px;
gap: 6px;
}
.pos-product-card__name {
font-size: 12px;
}
.pos-product-card__price {
font-size: 13px;
}
/* EN: Category tabs scrollable with momentum / VI: Tab danh mục cuộn với momentum */
.pos-category-tabs {
padding: 8px 8px;
gap: 6px;
-webkit-overflow-scrolling: touch;
}
.pos-category-tab {
padding: 10px 14px;
font-size: 13px;
min-height: 44px;
}
/* EN: Cart footer compact / VI: Footer giỏ hàng thu gọn */
.pos-cart-footer {
padding: 12px;
}
.pos-cart-total__value {
font-size: 18px;
}
/* EN: Dashboard stats single column / VI: Thống kê dashboard 1 cột */
.pos-dashboard__stats {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.pos-dashboard {
padding: 12px;
}
.pos-dashboard__title {
font-size: 16px;
}
.pos-dashboard__stat-value {
font-size: 18px;
}
/* EN: Payment quick amounts 2 columns / VI: Số tiền nhanh 2 cột */
.pos-payment-quick-amounts {
grid-template-columns: repeat(2, 1fr);
}
/* EN: Bottom navigation — visible on mobile / VI: Nav dưới — hiện trên mobile */
.pos-bottom-nav {
height: 56px;
}
.pos-bottom-nav__tab {
font-size: 10px;
min-height: 56px;
}
/* EN: History list compact / VI: Danh sách lịch sử thu gọn */
.pos-history__list {
padding: 8px;
}
.pos-history__card {
padding: 12px;
}
.pos-history__items-preview {
max-width: 50%;
font-size: 11px;
}
/* EN: Dialog full width on mobile / VI: Dialog full chiều rộng trên mobile */
.pos-dialog {
width: 100%;
max-width: 100%;
border-radius: 16px 16px 0 0;
max-height: 85vh;
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
}
/* ── LARGE DESKTOP: >= 1280px ── */
@media (min-width: 1280px) {
/* EN: Wider cart panel on large screens / VI: Panel giỏ hàng rộng hơn trên màn hình lớn */
.pos-cart-panel {
width: 400px;
min-width: 400px;
}
/* EN: Larger product grid items / VI: Mục lưới sản phẩm lớn hơn */
.pos-product-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
/* EN: Dashboard stats 4 columns / VI: Thống kê dashboard 4 cột */
.pos-dashboard__stats {
grid-template-columns: repeat(4, 1fr);
}
}
/* ═════════════════════════════════════════════════════════════════════════
14. TOUCH UTILITIES
EN: Touch-friendly utilities for POS on touch devices.
VI: Tiện ích thân thiện cảm ứng cho POS trên thiết bị cảm ứng.
═════════════════════════════════════════════════════════════════════════ */
/* EN: Prevent text selection on touch targets / VI: Ngăn chọn văn bản trên vùng chạm */
.pos-btn-checkout,
.pos-category-tab,
.pos-product-card,
.pos-payment-method-btn,
.pos-payment-quick-btn,
.pos-bottom-nav__tab,
.pos-mobile-toggle,
.pos-order-toggle,
.pos-sidebar__link {
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* EN: Safe area insets for notched devices / VI: Vùng an toàn cho thiết bị có tai thỏ */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.pos-bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
}
.pos-cart-footer {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.pos-order-drawer .pos-cart-footer {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}