Files
pos-system/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/PosLayout.razor
Ho Ngoc Hai b666d2f68d feat(pos): fix settings navigation and add receipt template management
- 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>
2026-03-29 03:43:55 +07:00

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();
}