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>
This commit is contained in:
Ho Ngoc Hai
2026-03-29 03:43:55 +07:00
parent c8a70f8d80
commit b666d2f68d
7 changed files with 781 additions and 2 deletions

View File

@@ -98,7 +98,7 @@
</a>
</div>
<div class="pos-sidebar__footer">
<a class="pos-sidebar__link" href="@(AuthState.GetPortalUrl())" @onclick="CloseSidebar">
<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>
@@ -217,7 +217,15 @@
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private void GoToPortal() => NavigationManager.NavigateTo(AuthState.GetPortalUrl());
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;

View File

@@ -0,0 +1,697 @@
@*
EN: Receipt template management — create, edit, preview, switch, delete receipt templates.
VI: Quản lý mẫu hoá đơn — tạo, sửa, xem trước, chuyển đổi, xoá mẫu hoá đơn in.
*@
@using System.Text.Json
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@* ═══ TEMPLATE LIST + EDITOR LAYOUT ═══ *@
<div style="display:flex;gap:24px;align-items:flex-start;">
@* ─── LEFT: Template list ─── *@
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="font-size:16px;font-weight:700;margin:0;">Danh sách mẫu hoá đơn</h3>
<button class="admin-btn-primary" @onclick="CreateNewTemplate" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="plus" style="width:16px;height:16px;"></i>Tạo mới
</button>
</div>
@if (_templates.Count == 0)
{
<div class="admin-panel">
<div class="admin-panel__body" style="text-align:center;padding:40px;">
<div style="width:64px;height:64px;border-radius:20px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<i data-lucide="receipt" style="width:28px;height:28px;color:var(--admin-orange-primary);"></i>
</div>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Chưa có mẫu hoá đơn nào.</p>
</div>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
@foreach (var tpl in _templates)
{
var isSelected = _editingTemplate?.Id == tpl.Id;
var capturedTpl = tpl;
<div @onclick="@(() => SelectTemplate(capturedTpl))"
style="border-radius:12px;border:2px solid @(isSelected ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");background:var(--admin-bg-elevated);cursor:pointer;overflow:hidden;transition:border-color 0.2s;">
@* EN: Mini receipt preview thumbnail *@
@* VI: Hình thu nhỏ xem trước hoá đơn *@
<div style="padding:16px;background:var(--admin-bg-base);display:flex;justify-content:center;">
<div style="width:100px;background:#fff;border-radius:4px;padding:8px;transform:scale(0.85);">
<div style="text-align:center;font-size:6px;color:#333;font-weight:700;margin-bottom:4px;">
@(string.IsNullOrWhiteSpace(capturedTpl.HeaderText) ? "TÊN CỬA HÀNG" : capturedTpl.HeaderText)
</div>
@if (capturedTpl.ShowItemList)
{
<div style="border-top:1px dashed #ccc;margin:3px 0;"></div>
<div style="font-size:5px;color:#666;">Sản phẩm x1 ... 50,000</div>
<div style="font-size:5px;color:#666;">Sản phẩm x2 ... 30,000</div>
}
@if (capturedTpl.ShowTotal)
{
<div style="border-top:1px dashed #ccc;margin:3px 0;"></div>
<div style="font-size:6px;color:#333;font-weight:700;text-align:right;">TỔNG: 80,000đ</div>
}
@if (!string.IsNullOrWhiteSpace(capturedTpl.FooterText))
{
<div style="text-align:center;font-size:5px;color:#999;margin-top:4px;">@capturedTpl.FooterText</div>
}
</div>
</div>
<div style="padding:12px 16px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-size:13px;font-weight:600;">@capturedTpl.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-top:2px;">@capturedTpl.PaperWidth &middot; @GetFontSizeLabel(capturedTpl.FontSize)</div>
</div>
@if (capturedTpl.IsDefault)
{
<span style="font-size:10px;font-weight:700;color:var(--admin-orange-primary);background:rgba(255,92,0,0.12);padding:3px 8px;border-radius:6px;">Mặc định</span>
}
</div>
</div>
}
</div>
}
</div>
@* ─── RIGHT: Editor + Live Preview ─── *@
@if (_editingTemplate != null)
{
<div style="width:720px;flex-shrink:0;position:sticky;top:24px;">
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;align-items:center;justify-content:space-between;">
<h3 class="admin-panel__title" style="display:flex;align-items:center;gap:8px;">
<i data-lucide="pencil" style="width:16px;height:16px;color:var(--admin-orange-primary);"></i>
Chỉnh sửa mẫu
</h3>
<button @onclick="@(() => _editingTemplate = null)" style="background:none;border:none;cursor:pointer;color:var(--admin-text-tertiary);padding:4px;">
<i data-lucide="x" style="width:18px;height:18px;"></i>
</button>
</div>
<div class="admin-panel__body" style="padding:0;">
<div style="display:flex;">
@* ─── Editor fields ─── *@
<div style="flex:1;padding:20px;border-right:1px solid var(--admin-border-subtle);max-height:600px;overflow-y:auto;">
@* EN: Template name *@
@* VI: Tên mẫu hoá đơn *@
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên mẫu</label>
<input type="text" @bind="_editingTemplate.Name" @bind:event="oninput"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
@* EN: Paper width + Font size *@
@* VI: Khổ giấy + Cỡ chữ *@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khổ giấy</label>
<select @bind="_editingTemplate.PaperWidth"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);color:var(--admin-text-primary);font-size:14px;">
<option value="58mm">58mm</option>
<option value="80mm">80mm</option>
</select>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Cỡ chữ</label>
<select @bind="_editingTemplate.FontSize"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);color:var(--admin-text-primary);font-size:14px;">
<option value="small">Nhỏ (11px)</option>
<option value="medium">Trung bình (13px)</option>
<option value="large">Lớn (15px)</option>
</select>
</div>
</div>
@* EN: Header + Footer text *@
@* VI: Tiêu đề + Chân hoá đơn *@
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tiêu đề hoá đơn</label>
<input type="text" @bind="_editingTemplate.HeaderText" @bind:event="oninput" placeholder="Tên cửa hàng / tiêu đề"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Chân hoá đơn</label>
<input type="text" @bind="_editingTemplate.FooterText" @bind:event="oninput" placeholder="Cảm ơn quý khách!"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
@* EN: Wifi password (conditional) *@
@* VI: Mật khẩu wifi (nếu bật) *@
@if (_editingTemplate.ShowWifiPassword)
{
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mật khẩu Wifi</label>
<input type="text" @bind="_editingTemplate.WifiPassword" @bind:event="oninput" placeholder="wifi-password-123"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);font-family:monospace;" />
</div>
}
@* EN: Field toggles *@
@* VI: Bật/tắt các trường hiển thị *@
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">Hiển thị trường</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
@{
void RenderToggle(string label, bool isOn, Action<bool> setter) {
<div @onclick="@(() => { setter(!isOn); StateHasChanged(); })"
style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);cursor:pointer;transition:background 0.15s;">
<div style="width:32px;height:18px;border-radius:9px;background:@(isOn ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");position:relative;transition:0.2s;flex-shrink:0;">
<div style="width:14px;height:14px;border-radius:50%;background:white;position:absolute;top:2px;@(isOn ? "right:2px;" : "left:2px;");transition:0.2s;"></div>
</div>
<span style="font-size:12px;font-weight:500;white-space:nowrap;">@label</span>
</div>;
}
}
@{ RenderToggle("Logo", _editingTemplate.ShowLogo, v => _editingTemplate.ShowLogo = v); }
@{ RenderToggle("Địa chỉ", _editingTemplate.ShowAddress, v => _editingTemplate.ShowAddress = v); }
@{ RenderToggle("Số điện thoại", _editingTemplate.ShowPhone, v => _editingTemplate.ShowPhone = v); }
@{ RenderToggle("Mã số thuế", _editingTemplate.ShowTaxId, v => _editingTemplate.ShowTaxId = v); }
@{ RenderToggle("Mã đơn hàng", _editingTemplate.ShowOrderNumber, v => _editingTemplate.ShowOrderNumber = v); }
@{ RenderToggle("Ngày giờ", _editingTemplate.ShowDateTime, v => _editingTemplate.ShowDateTime = v); }
@{ RenderToggle("Tên nhân viên", _editingTemplate.ShowStaffName, v => _editingTemplate.ShowStaffName = v); }
@{ RenderToggle("Danh sách SP", _editingTemplate.ShowItemList, v => _editingTemplate.ShowItemList = v); }
@{ RenderToggle("Tạm tính", _editingTemplate.ShowSubtotal, v => _editingTemplate.ShowSubtotal = v); }
@{ RenderToggle("Phí dịch vụ", _editingTemplate.ShowServiceCharge, v => _editingTemplate.ShowServiceCharge = v); }
@{ RenderToggle("VAT %", _editingTemplate.ShowVat, v => _editingTemplate.ShowVat = v); }
@{ RenderToggle("Giảm giá", _editingTemplate.ShowDiscount, v => _editingTemplate.ShowDiscount = v); }
@{ RenderToggle("Tổng cộng", _editingTemplate.ShowTotal, v => _editingTemplate.ShowTotal = v); }
@{ RenderToggle("Phương thức TT", _editingTemplate.ShowPaymentMethod, v => _editingTemplate.ShowPaymentMethod = v); }
@{ RenderToggle("Tiền thừa", _editingTemplate.ShowChangeAmount, v => _editingTemplate.ShowChangeAmount = v); }
@{ RenderToggle("Mã giao dịch", _editingTemplate.ShowTransactionId, v => _editingTemplate.ShowTransactionId = v); }
@{ RenderToggle("Barcode/QR", _editingTemplate.ShowBarcode, v => _editingTemplate.ShowBarcode = v); }
@{ RenderToggle("Mật khẩu Wifi", _editingTemplate.ShowWifiPassword, v => _editingTemplate.ShowWifiPassword = v); }
</div>
@* EN: Action buttons *@
@* VI: Các nút hành động *@
<div style="margin-top:20px;display:flex;flex-wrap:wrap;gap:8px;">
<button class="admin-btn-primary" @onclick="SaveTemplate" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="save" style="width:14px;height:14px;"></i>Lưu mẫu
</button>
@if (!_editingTemplate.IsDefault)
{
<button @onclick="SetAsDefault"
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-orange-primary);background:transparent;color:var(--admin-orange-primary);cursor:pointer;font-size:13px;font-weight:600;">
<i data-lucide="star" style="width:14px;height:14px;"></i>Đặt mặc định
</button>
}
@if (_templates.Count > 1)
{
<button @onclick="DeleteTemplate"
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid #EF4444;background:transparent;color:#EF4444;cursor:pointer;font-size:13px;font-weight:600;">
<i data-lucide="trash-2" style="width:14px;height:14px;"></i>Xoá
</button>
}
</div>
</div>
@* ─── Live Preview ─── *@
<div style="width:300px;padding:20px;display:flex;flex-direction:column;align-items:center;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:12px;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:0.5px;">Xem trước</label>
@{
var t = _editingTemplate;
var paperW = t.PaperWidth == "58mm" ? "200px" : "260px";
var fontSize = GetFontSizePx(t.FontSize);
}
<div style="width:@paperW;background:#FFFFFF;border-radius:4px;padding:16px 12px;font-family:'Courier New',monospace;color:#1a1a1a;font-size:@fontSize;box-shadow:0 2px 12px rgba(0,0,0,0.15);position:relative;">
@* EN: Torn paper top edge *@
<div style="position:absolute;top:-6px;left:0;right:0;height:6px;background:repeating-linear-gradient(90deg,transparent,transparent 4px,#FFFFFF 4px,#FFFFFF 8px);"></div>
@* ── Header ── *@
@if (t.ShowLogo)
{
<div style="text-align:center;margin-bottom:6px;">
<div style="width:40px;height:40px;border-radius:8px;background:#f0f0f0;display:inline-flex;align-items:center;justify-content:center;">
<span style="font-size:18px;color:#999;">&#9741;</span>
</div>
</div>
}
<div style="text-align:center;font-weight:700;font-size:calc(@fontSize + 2px);margin-bottom:2px;">
@(string.IsNullOrWhiteSpace(t.HeaderText) ? "TÊN CỬA HÀNG" : t.HeaderText)
</div>
@if (t.ShowAddress)
{
<div style="text-align:center;font-size:calc(@fontSize - 1px);color:#666;">123 Nguyễn Huệ, Q.1, TP.HCM</div>
}
@if (t.ShowPhone)
{
<div style="text-align:center;font-size:calc(@fontSize - 1px);color:#666;">ĐT: 0909 123 456</div>
}
@if (t.ShowTaxId)
{
<div style="text-align:center;font-size:calc(@fontSize - 1px);color:#666;">MST: 0123456789</div>
}
<div style="border-top:1px dashed #ccc;margin:8px 0;"></div>
@* ── Order info ── *@
@if (t.ShowOrderNumber)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Đơn #:</span><span style="font-weight:600;">HD-00042</span>
</div>
}
@if (t.ShowDateTime)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Ngày:</span><span>29/03/2026 14:35</span>
</div>
}
@if (t.ShowStaffName)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Thu ngân:</span><span>Nguyễn Văn A</span>
</div>
}
@if (t.ShowOrderNumber || t.ShowDateTime || t.ShowStaffName)
{
<div style="border-top:1px dashed #ccc;margin:8px 0;"></div>
}
@* ── Item list ── *@
@if (t.ShowItemList)
{
<div style="font-size:calc(@fontSize - 1px);margin-bottom:2px;">
<div style="display:flex;justify-content:space-between;font-weight:600;margin-bottom:4px;border-bottom:1px solid #eee;padding-bottom:4px;">
<span>Sản phẩm</span><span>Tiền</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:2px;">
<span>Cà phê sữa đá x2</span><span>58,000</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:2px;">
<span>Trà đào cam sả x1</span><span>45,000</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:2px;">
<span>Bánh mì bơ tỏi x1</span><span>25,000</span>
</div>
</div>
<div style="border-top:1px dashed #ccc;margin:8px 0;"></div>
}
@* ── Totals ── *@
@if (t.ShowSubtotal)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Tạm tính:</span><span>128,000</span>
</div>
}
@if (t.ShowServiceCharge)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Phí dịch vụ (5%):</span><span>6,400</span>
</div>
}
@if (t.ShowVat)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>VAT (10%):</span><span>12,800</span>
</div>
}
@if (t.ShowDiscount)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);color:#d32f2f;">
<span>Giảm giá:</span><span>-10,000</span>
</div>
}
@if (t.ShowTotal)
{
<div style="border-top:1px dashed #ccc;margin:6px 0;"></div>
<div style="display:flex;justify-content:space-between;font-weight:700;font-size:calc(@fontSize + 2px);">
<span>TỔNG CỘNG:</span><span>@(ComputePreviewTotal())đ</span>
</div>
}
@* ── Payment info ── *@
@if (t.ShowPaymentMethod)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);margin-top:4px;">
<span>Thanh toán:</span><span>Tiền mặt</span>
</div>
}
@if (t.ShowChangeAmount)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Tiền khách đưa:</span><span>150,000</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 1px);">
<span>Tiền thừa:</span><span>@(ComputePreviewChange())</span>
</div>
}
@if (t.ShowTransactionId)
{
<div style="display:flex;justify-content:space-between;font-size:calc(@fontSize - 2px);color:#999;margin-top:4px;">
<span>Mã GD:</span><span>TXN-20260329-0042</span>
</div>
}
@* ── Barcode ── *@
@if (t.ShowBarcode)
{
<div style="text-align:center;margin-top:8px;">
<div style="display:inline-block;padding:8px;background:#f5f5f5;border-radius:4px;">
<div style="width:80px;height:80px;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);gap:1px;">
@for (var i = 0; i < 64; i++)
{
var fill = (i % 3 == 0 || i % 7 == 0) ? "#1a1a1a" : "#fff";
<div style="background:@fill;"></div>
}
</div>
</div>
</div>
}
@* ── Wifi ── *@
@if (t.ShowWifiPassword)
{
<div style="text-align:center;margin-top:6px;font-size:calc(@fontSize - 1px);color:#666;">
Wifi: <strong>@(t.WifiPassword ?? "goodgo-wifi")</strong>
</div>
}
@* ── Footer ── *@
@if (!string.IsNullOrWhiteSpace(t.FooterText))
{
<div style="border-top:1px dashed #ccc;margin:8px 0;"></div>
<div style="text-align:center;font-size:calc(@fontSize - 1px);color:#666;font-style:italic;">@t.FooterText</div>
}
@* EN: Torn paper bottom edge *@
<div style="position:absolute;bottom:-6px;left:0;right:0;height:6px;background:repeating-linear-gradient(90deg,transparent,transparent 4px,#FFFFFF 4px,#FFFFFF 8px);"></div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
private List<ReceiptTemplate> _templates = new();
private ReceiptTemplate? _editingTemplate;
private string StorageKey => $"aPOS_receipt_templates_{ShopId}";
/// <summary>
/// EN: Receipt template data model — stored in localStorage per shop.
/// VI: Mô hình dữ liệu mẫu hoá đơn — lưu trong localStorage theo từng cửa hàng.
/// </summary>
private class ReceiptTemplate
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "Mẫu mặc định";
public bool IsDefault { get; set; }
public string PaperWidth { get; set; } = "80mm";
public string FontSize { get; set; } = "medium";
public string HeaderText { get; set; } = "";
public string FooterText { get; set; } = "Cảm ơn quý khách!";
public bool ShowLogo { get; set; } = true;
public bool ShowAddress { get; set; } = true;
public bool ShowPhone { get; set; } = true;
public bool ShowTaxId { get; set; }
public bool ShowOrderNumber { get; set; } = true;
public bool ShowDateTime { get; set; } = true;
public bool ShowStaffName { get; set; }
public bool ShowItemList { get; set; } = true;
public bool ShowSubtotal { get; set; } = true;
public bool ShowServiceCharge { get; set; }
public bool ShowVat { get; set; }
public bool ShowDiscount { get; set; } = true;
public bool ShowTotal { get; set; } = true;
public bool ShowPaymentMethod { get; set; } = true;
public bool ShowChangeAmount { get; set; } = true;
public bool ShowTransactionId { get; set; } = true;
public bool ShowBarcode { get; set; }
public bool ShowWifiPassword { get; set; }
public string? WifiPassword { get; set; }
}
protected override async Task OnInitializedAsync()
{
await LoadTemplates();
}
/// <summary>
/// EN: Load templates from localStorage, create default if none exist.
/// VI: Tải mẫu từ localStorage, tạo mẫu mặc định nếu chưa có.
/// </summary>
private async Task LoadTemplates()
{
try
{
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageKey);
if (!string.IsNullOrWhiteSpace(json))
{
var loaded = JsonSerializer.Deserialize<List<ReceiptTemplate>>(json, _jsonOptions);
if (loaded != null && loaded.Count > 0)
{
_templates = loaded;
return;
}
}
}
catch { /* localStorage read error — use defaults */ }
// EN: Create default template if none exist
// VI: Tạo mẫu mặc định nếu chưa có
_templates = new List<ReceiptTemplate>
{
new ReceiptTemplate
{
Id = Guid.NewGuid(),
Name = "Mẫu mặc định",
IsDefault = true,
PaperWidth = "80mm",
FontSize = "medium",
HeaderText = "",
FooterText = "Cảm ơn quý khách!",
ShowLogo = true,
ShowAddress = true,
ShowPhone = true,
ShowOrderNumber = true,
ShowDateTime = true,
ShowItemList = true,
ShowSubtotal = true,
ShowDiscount = true,
ShowTotal = true,
ShowPaymentMethod = true,
ShowChangeAmount = true,
ShowTransactionId = true
}
};
await PersistTemplates();
}
/// <summary>
/// EN: Persist templates to localStorage.
/// VI: Lưu mẫu vào localStorage.
/// </summary>
private async Task PersistTemplates()
{
try
{
var json = JsonSerializer.Serialize(_templates, _jsonOptions);
await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, json);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi lưu mẫu: {ex.Message}", MudBlazor.Severity.Error);
}
}
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
/// <summary>
/// EN: Select a template for editing (deep clone to avoid live mutation until save).
/// VI: Chọn mẫu để sửa (clone sâu để tránh thay đổi trực tiếp cho đến khi lưu).
/// </summary>
private void SelectTemplate(ReceiptTemplate tpl)
{
_editingTemplate = CloneTemplate(tpl);
}
/// <summary>
/// EN: Create a new template with default values.
/// VI: Tạo mẫu mới với giá trị mặc định.
/// </summary>
private void CreateNewTemplate()
{
_editingTemplate = new ReceiptTemplate
{
Id = Guid.NewGuid(),
Name = $"Mẫu {_templates.Count + 1}",
IsDefault = false,
PaperWidth = "80mm",
FontSize = "medium",
FooterText = "Cảm ơn quý khách!"
};
}
/// <summary>
/// EN: Save editing template (insert or update).
/// VI: Lưu mẫu đang sửa (thêm mới hoặc cập nhật).
/// </summary>
private async Task SaveTemplate()
{
if (_editingTemplate == null) return;
var existing = _templates.FindIndex(t => t.Id == _editingTemplate.Id);
if (existing >= 0)
{
_templates[existing] = CloneTemplate(_editingTemplate);
}
else
{
_templates.Add(CloneTemplate(_editingTemplate));
}
await PersistTemplates();
Snackbar.Add("Đã lưu mẫu hoá đơn!", MudBlazor.Severity.Success);
StateHasChanged();
}
/// <summary>
/// EN: Set editing template as default.
/// VI: Đặt mẫu đang sửa làm mặc định.
/// </summary>
private async Task SetAsDefault()
{
if (_editingTemplate == null) return;
foreach (var tItem in _templates) tItem.IsDefault = false;
var target = _templates.Find(tItem => tItem.Id == _editingTemplate.Id);
if (target != null) target.IsDefault = true;
_editingTemplate.IsDefault = true;
await PersistTemplates();
Snackbar.Add("Đã đặt mẫu mặc định!", MudBlazor.Severity.Success);
StateHasChanged();
}
/// <summary>
/// EN: Delete editing template (cannot delete last or default).
/// VI: Xoá mẫu đang sửa (không được xoá mẫu cuối hoặc mặc định).
/// </summary>
private async Task DeleteTemplate()
{
if (_editingTemplate == null) return;
if (_templates.Count <= 1)
{
Snackbar.Add("Không thể xoá mẫu duy nhất.", MudBlazor.Severity.Warning);
return;
}
if (_editingTemplate.IsDefault)
{
Snackbar.Add("Không thể xoá mẫu mặc định. Hãy chuyển mặc định sang mẫu khác trước.", MudBlazor.Severity.Warning);
return;
}
var confirmed = await DialogService.ShowMessageBox(
"Xoá mẫu hoá đơn",
$"Bạn có chắc muốn xoá mẫu \"{_editingTemplate.Name}\"?",
yesText: "Xoá", cancelText: "Hủy");
if (confirmed != true) return;
_templates.RemoveAll(tItem => tItem.Id == _editingTemplate.Id);
_editingTemplate = null;
await PersistTemplates();
Snackbar.Add("Đã xoá mẫu hoá đơn.", MudBlazor.Severity.Success);
StateHasChanged();
}
/// <summary>
/// EN: Get the name of the currently active (default) template.
/// VI: Lấy tên mẫu đang kích hoạt (mặc định).
/// </summary>
public string GetActiveTemplateName()
{
return _templates.FirstOrDefault(tItem => tItem.IsDefault)?.Name ?? _templates.FirstOrDefault()?.Name ?? "—";
}
/// <summary>
/// EN: Deep clone a template to avoid reference sharing.
/// VI: Clone sâu mẫu hoá đơn để tránh chia sẻ tham chiếu.
/// </summary>
private static ReceiptTemplate CloneTemplate(ReceiptTemplate src) => new()
{
Id = src.Id,
Name = src.Name,
IsDefault = src.IsDefault,
PaperWidth = src.PaperWidth,
FontSize = src.FontSize,
HeaderText = src.HeaderText,
FooterText = src.FooterText,
ShowLogo = src.ShowLogo,
ShowAddress = src.ShowAddress,
ShowPhone = src.ShowPhone,
ShowTaxId = src.ShowTaxId,
ShowOrderNumber = src.ShowOrderNumber,
ShowDateTime = src.ShowDateTime,
ShowStaffName = src.ShowStaffName,
ShowItemList = src.ShowItemList,
ShowSubtotal = src.ShowSubtotal,
ShowServiceCharge = src.ShowServiceCharge,
ShowVat = src.ShowVat,
ShowDiscount = src.ShowDiscount,
ShowTotal = src.ShowTotal,
ShowPaymentMethod = src.ShowPaymentMethod,
ShowChangeAmount = src.ShowChangeAmount,
ShowTransactionId = src.ShowTransactionId,
ShowBarcode = src.ShowBarcode,
ShowWifiPassword = src.ShowWifiPassword,
WifiPassword = src.WifiPassword
};
private static string GetFontSizeLabel(string size) => size switch
{
"small" => "Nhỏ",
"large" => "Lớn",
_ => "Trung bình"
};
private static string GetFontSizePx(string size) => size switch
{
"small" => "11px",
"large" => "15px",
_ => "13px"
};
/// <summary>
/// EN: Compute preview total based on shown fields.
/// VI: Tính tổng xem trước dựa trên các trường hiển thị.
/// </summary>
private string ComputePreviewTotal()
{
if (_editingTemplate == null) return "128,000";
decimal total = 128000;
if (_editingTemplate.ShowServiceCharge) total += 6400;
if (_editingTemplate.ShowVat) total += 12800;
if (_editingTemplate.ShowDiscount) total -= 10000;
return total.ToString("N0");
}
private string ComputePreviewChange()
{
if (_editingTemplate == null) return "22,000";
decimal total = 128000;
if (_editingTemplate.ShowServiceCharge) total += 6400;
if (_editingTemplate.ShowVat) total += 12800;
if (_editingTemplate.ShowDiscount) total -= 10000;
var change = 150000 - total;
return change.ToString("N0");
}
}

View File

@@ -255,6 +255,10 @@
<ShopSettings ShopId="@(_shopGuid ?? Guid.Empty)" ShopName="@_shopName" VerticalLabel="@_verticalLabel" ShopStatus="@_shopStatus" />
break;
case "receipt-templates":
<ReceiptTemplates ShopId="@(_shopGuid ?? Guid.Empty)" />
break;
case "schedule":
<ShopSchedule ShopId="@(_shopGuid ?? Guid.Empty)" SubSection="schedule" />
break;
@@ -503,6 +507,7 @@
case "shifts": _sectionTitle = "Ca làm việc"; _sectionIcon = "clock-4"; _sectionDescription = "Lịch ca làm, phân ca."; break;
case "ai-chat": _sectionTitle = "AI Assistant"; _sectionIcon = "bot"; _sectionDescription = "Trợ lý AI — tương tác với hệ thống POS qua chat."; break;
case "drive": _sectionTitle = "Lưu trữ"; _sectionIcon = "hard-drive"; _sectionDescription = "Quản lý tệp và thư mục."; break;
case "receipt-templates": _sectionTitle = "Template Hoá đơn"; _sectionIcon = "receipt"; _sectionDescription = "Quản lý mẫu hoá đơn in."; break;
default: _sectionTitle = Section ?? "Trang"; _sectionIcon = "layout-dashboard"; _sectionDescription = "Trang đang phát triển."; break;
}
}

View File

@@ -1,10 +1,12 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@using System.Text.Json
@inject PosDataService DataService
@inject MerchantApiService MerchantApi
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@* ─── Shop info (read-only) ─── *@
<div class="admin-panel">
@@ -78,6 +80,29 @@
}
</div>
@* ─── EN: Receipt Template Configuration ─── *@
@* ─── VI: Cấu hình mẫu hoá đơn in ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="display:flex;align-items:center;gap:8px;">
<i data-lucide="receipt" style="width:18px;height:18px;color:var(--admin-orange-primary);"></i>
Hoá đơn in
</h3>
</div>
<div class="admin-panel__body" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-size:14px;font-weight:600;">Quản lý mẫu hoá đơn</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-top:2px;">
Mẫu đang dùng: <strong style="color:var(--admin-orange-primary);">@(_activeReceiptTemplateName ?? "Mẫu mặc định")</strong>
</div>
</div>
<button @onclick="NavigateToReceiptTemplates"
style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-orange-primary);background:rgba(255,92,0,0.08);color:var(--admin-orange-primary);cursor:pointer;font-size:13px;font-weight:600;">
<i data-lucide="settings-2" style="width:14px;height:14px;"></i>Quản lý template hoá đơn
</button>
</div>
</div>
@* ─── EN: AI Assistant Configuration ─── *@
@* ─── VI: Cấu hình AI Assistant ─── *@
<div class="admin-panel" style="margin-top:16px;">
@@ -264,9 +289,45 @@
{
await LoadShopSettings();
await LoadAiConfig();
await LoadActiveReceiptTemplateName();
}
}
/// <summary>
/// EN: Load the name of the active (default) receipt template from localStorage.
/// VI: Tải tên mẫu hoá đơn mặc định từ localStorage.
/// </summary>
private async Task LoadActiveReceiptTemplateName()
{
try
{
var key = $"aPOS_receipt_templates_{ShopId}";
var json = await JS.InvokeAsync<string?>("localStorage.getItem", key);
if (!string.IsNullOrWhiteSpace(json))
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var templates = JsonSerializer.Deserialize<List<ReceiptTemplateInfo>>(json, options);
_activeReceiptTemplateName = templates?.FirstOrDefault(t => t.IsDefault)?.Name
?? templates?.FirstOrDefault()?.Name;
}
}
catch { /* non-fatal */ }
}
private record ReceiptTemplateInfo(string? Name, bool IsDefault);
/// <summary>
/// EN: Navigate to receipt templates management page.
/// VI: Điều hướng đến trang quản lý mẫu hoá đơn.
/// </summary>
private void NavigateToReceiptTemplates()
{
// EN: Build URL using ShopId from parameter — navigate to receipt-templates section of ShopPage
// VI: Xây dựng URL từ ShopId — điều hướng tới section receipt-templates của ShopPage
var shopIdStr = ShopId.ToString();
NavigationManager.NavigateTo($"/admin/shop/{shopIdStr}/receipt-templates");
}
private async Task LoadAiConfig()
{
try
@@ -358,6 +419,10 @@
StateHasChanged();
}
// EN: Receipt template state
// VI: Trạng thái mẫu hoá đơn
private string? _activeReceiptTemplateName;
// EN: AI config state
// VI: Trạng thái cấu hình AI
private bool _aiEnabled;

View File

@@ -44,6 +44,7 @@ public static class ShopSidebarConfig
new("Shop_Menu_AiChat", "bot", "ai-chat"),
new("Shop_Menu_Drive", "hard-drive", "drive"),
new("Shop_Menu_Settings", "settings", "settings"),
new("Shop_Menu_ReceiptTemplates", "receipt", "receipt-templates"),
};
List<MenuItem> items = vertical switch
@@ -125,6 +126,7 @@ public static class ShopSidebarConfig
items.Add(new("Shop_Menu_AiChat", "bot", "ai-chat"));
items.Add(new("Shop_Menu_Drive", "hard-drive", "drive"));
items.Add(new("Shop_Menu_Settings", "settings", "settings"));
items.Add(new("Shop_Menu_ReceiptTemplates", "receipt", "receipt-templates"));
}
else
{

View File

@@ -395,6 +395,7 @@
"Shop_Menu_AiChat": "AI Assistant",
"Shop_Menu_Drive": "Storage",
"Shop_Menu_Settings": "Settings",
"Shop_Menu_ReceiptTemplates": "Receipt Templates",
"Vertical_Cafe": "Café",
"Vertical_Restaurant": "Restaurant / Bar",
"Vertical_Karaoke": "Karaoke",

View File

@@ -395,6 +395,7 @@
"Shop_Menu_AiChat": "AI Assistant",
"Shop_Menu_Drive": "Lưu trữ",
"Shop_Menu_Settings": "Thiết lập",
"Shop_Menu_ReceiptTemplates": "Hoá đơn in",
"Vertical_Cafe": "Café",
"Vertical_Restaurant": "Nhà hàng / Bar",
"Vertical_Karaoke": "Karaoke",