- Fix POS settings button redirecting to /auth/login when UserRole is
not loaded — now navigates to /admin/shop/{shopId}/overview
- Add receipt template management page with full CRUD:
- Create/edit/delete receipt templates stored in localStorage
- Live preview of thermal receipt (80mm/58mm paper width)
- 18 toggleable fields (logo, address, phone, tax ID, items, etc.)
- Customizable header, footer, font size, paper width
- Set default template for POS printing
- Add "Hoá đơn in" section to shop settings with active template info
- Add sidebar menu item and route for receipt-templates
- Add vi-VN/en-US localization keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
238 lines
11 KiB
Plaintext
238 lines
11 KiB
Plaintext
@*
|
|
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
|
|
@inject WebClientTpos.Client.Services.AuthStateService AuthState
|
|
|
|
@* EN: Theme providers moved to App.razor (FRONT-W-06) — DefaultDark is the ThemeStateService default. *@
|
|
@* VI: Theme providers đã chuyển về App.razor (FRONT-W-06) — DefaultDark là mặc định của ThemeStateService. *@
|
|
|
|
<div class="pos-layout">
|
|
@* ═══ 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"
|
|
aria-label="@(_sidebarOpen ? "Đóng menu" : "Mở menu")"
|
|
aria-expanded="@(_sidebarOpen ? "true" : "false")"
|
|
aria-controls="pos-sidebar">
|
|
<i data-lucide="@(_sidebarOpen ? "x" : "menu")" style="width:20px;height:20px;" aria-hidden="true"></i>
|
|
</button>
|
|
<span class="pos-status-bar__logo">aPOS POS</span>
|
|
<span class="pos-status-bar__store">@StoreName</span>
|
|
</div>
|
|
<div class="pos-status-bar__right">
|
|
<div class="pos-status-bar__indicator pos-status-bar__indicator--online">
|
|
<span style="width:6px;height:6px;border-radius:100px;background:currentColor;"></span>
|
|
<span>Online</span>
|
|
</div>
|
|
<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"
|
|
aria-label="@(_orderCount > 0 ? $"Đơn hàng ({_orderCount})" : "Đơn hàng")"
|
|
aria-expanded="@(_orderPanelOpen ? "true" : "false")"
|
|
aria-controls="pos-order-drawer-content">
|
|
<i data-lucide="shopping-cart" style="width:18px;height:18px;" aria-hidden="true"></i>
|
|
@if (_orderCount > 0)
|
|
{
|
|
<span class="pos-order-toggle__badge" aria-hidden="true">@_orderCount</span>
|
|
}
|
|
</button>
|
|
<button class="admin-icon-btn pos-admin-btn" @onclick="GoToPortal" aria-label="Quản lý">
|
|
<i data-lucide="settings" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
@* ═══ MAIN CONTENT ═══ *@
|
|
<div class="pos-main">
|
|
@* 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 id="pos-sidebar"
|
|
class="pos-sidebar @(_sidebarOpen ? "pos-sidebar--open" : "")"
|
|
aria-label="POS navigation">
|
|
<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" aria-label="Đóng menu">
|
|
<i data-lucide="x" style="width:18px;height:18px;" aria-hidden="true"></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="@(!string.IsNullOrEmpty(_shopIdStr) ? $"/admin/shop/{_shopIdStr}/overview" : AuthState.GetPortalUrl())" @onclick="CloseSidebar">
|
|
<i data-lucide="settings" style="width:18px;height:18px;"></i>
|
|
<span>Quản lý</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" aria-label="Đóng panel đơn hàng">
|
|
<i data-lucide="x" style="width:18px;height:18px;" aria-hidden="true"></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>
|
|
|
|
@code {
|
|
private string StoreName { get; set; } = "aPOS 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
|
|
_timer = new Timer(_ =>
|
|
{
|
|
_currentTime = DateTime.Now.ToString("HH:mm");
|
|
InvokeAsync(StateHasChanged);
|
|
}, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
|
|
|
|
// EN: Extract shopId from URL path: /pos/{shopId}/...
|
|
// VI: Trích xuất shopId từ URL: /pos/{shopId}/...
|
|
try
|
|
{
|
|
var uri = new Uri(NavigationManager.Uri);
|
|
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
// 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;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// EN: Fallback to default name / VI: Dùng tên mặc định nếu lỗi
|
|
}
|
|
}
|
|
|
|
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 GoToPortal()
|
|
{
|
|
// EN: Navigate to shop admin page if shopId is available, otherwise fallback to portal URL
|
|
// VI: Điều hướng đến trang admin shop nếu có shopId, nếu không fallback về portal URL
|
|
if (!string.IsNullOrEmpty(_shopIdStr))
|
|
NavigationManager.NavigateTo($"/admin/shop/{_shopIdStr}/overview");
|
|
else
|
|
NavigationManager.NavigateTo(AuthState.GetPortalUrl());
|
|
}
|
|
|
|
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();
|
|
}
|