From c31881f7b65eddfda23f05fd1eab068ca89c203d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 29 Mar 2026 10:08:28 +0700 Subject: [PATCH] feat(pos): integrate receipt templates into POS printing flow - Create ReceiptPrintService that reads default template from localStorage and generates receipt HTML with template settings (paper width, font size, field visibility, header/footer) - Replace 3 hardcoded PrintReceipt methods in CafeDesktop.razor with ReceiptPrintService.PrintAsync() calls - Load shop info (name, phone) for receipt header - Register ReceiptPrintService in DI container Receipt templates configured in /admin/shop/{id}/receipt-templates are now applied when printing from POS. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Pages/Pos/Cafe/CafeDesktop.razor | 162 +++++-------- .../src/WebClientTpos.Client/Program.cs | 1 + .../Services/ReceiptPrintService.cs | 224 ++++++++++++++++++ 3 files changed, 281 insertions(+), 106 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ReceiptPrintService.cs diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor index 2fb4f001..892ad327 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor @@ -10,6 +10,7 @@ @using WebClientTpos.Client.Services @inject PosDataService DataService @inject IJSRuntime JS +@inject ReceiptPrintService ReceiptPrint @* ═══════════════ MAIN CONTENT AREA + VERTICAL NAV ═══════════════ *@
@@ -756,6 +757,11 @@ private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty); private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount); + // EN: Shop info for receipt printing / VI: Thông tin shop cho in hoá đơn + private string? _shopName; + private string? _shopAddress; + private string? _shopPhone; + // Voucher state private string _voucherCode = ""; private string? _voucherMessage; @@ -770,7 +776,12 @@ { var productsTask = DataService.GetProductsAsync(ShopId); var categoriesTask = DataService.GetCategoriesAsync(ShopId); - await Task.WhenAll(productsTask, categoriesTask); + var shopTask = DataService.GetShopByIdAsync(ShopId); + await Task.WhenAll(productsTask, categoriesTask, shopTask); + + // EN: Load shop info for receipt / VI: Tải thông tin shop cho hoá đơn + var shop = await shopTask; + if (shop != null) { _shopName = shop.Name; _shopPhone = shop.Phone; } var apiProducts = await productsTask; var apiCategories = await categoriesTask; @@ -1047,49 +1058,25 @@ private async Task PrintReceipt() { var payLabel = _lastPaymentMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; - var now = DateTime.Now; - // EN: Build item rows HTML / VI: Tạo HTML hàng sản phẩm - var sb = new System.Text.StringBuilder(); - foreach (var item in _lastReceiptItems) + // EN: Use ReceiptPrintService to generate HTML from saved template + // VI: Dùng ReceiptPrintService để tạo HTML từ mẫu đã lưu + await ReceiptPrint.PrintAsync(ShopId, new ReceiptData { - sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(item.Name)}"); - sb.AppendLine($"{item.Qty}"); - sb.AppendLine($"{item.Price:N0}"); - sb.AppendLine($"{item.Qty * item.Price:N0}"); - } - - var receiptHtml = "" + - $"Hóa đơn - {_lastTransactionId}" + - "" + - "
aPOS POS
" + - "
Hệ thống quản lý bán hàng thông minh
" + - "
" + - $"
Mã đơn: {_lastTransactionId}
" + - $"
Ngày: {now:dd/MM/yyyy} — {now:HH:mm:ss}
" + - $"
Thanh toán: {payLabel}
" + - "
" + - "" + - sb.ToString() + - "
Sản phẩmSLĐ.GiáT.Tiền
" + - $"
TỔNG CỘNG{_lastOrderTotal:N0}₫
" + - "
" + - "
Cảm ơn quý khách! Hẹn gặp lại
" + - "
Powered by aPOS Platform
" + - "" + - ""; - - // EN: Open popup window and write receipt HTML / VI: Mở popup và ghi HTML hóa đơn - // EN: Call JS helper to open print window / VI: Gọi JS helper mở cửa sổ in - await JS.InvokeVoidAsync("printPosReceipt", receiptHtml); + ShopName = _shopName ?? "Cửa hàng", + ShopAddress = _shopAddress, + ShopPhone = _shopPhone, + OrderNumber = _lastTransactionId, + OrderDate = DateTime.Now, + Items = _lastReceiptItems.Select(i => new ReceiptItem(i.Name, i.Qty, i.Price)).ToList(), + Subtotal = _lastReceiptItems.Sum(i => i.Qty * i.Price), + DiscountAmount = _discountAmount, + Total = _lastOrderTotal, + PaymentMethod = payLabel, + AmountTendered = _lastPaymentMethod == "cash" ? _receivedAmount : null, + ChangeAmount = _lastPaymentMethod == "cash" && _receivedAmount > _lastOrderTotal ? _receivedAmount - _lastOrderTotal : null, + TransactionId = _lastTransactionId + }); } // ═══════════════ HISTORY TAB — API-driven ═══════════════ @@ -1200,47 +1187,23 @@ { if (_selectedOrderDetail?.Order == null) return; var od = _selectedOrderDetail.Order; - var items = _selectedOrderDetail.Items ?? new(); var payLabel = MapPaymentMethodLabel(od.PaymentMethod); + var items = (_selectedOrderDetail.Items ?? new()) + .Select(i => new ReceiptItem(i.ProductName ?? "—", i.Quantity, i.UnitPrice)).ToList(); - var sb = new System.Text.StringBuilder(); - foreach (var item in items) + await ReceiptPrint.PrintAsync(ShopId, new ReceiptData { - sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(item.ProductName ?? "—")}"); - sb.AppendLine($"{item.Quantity}"); - sb.AppendLine($"{item.UnitPrice:N0}"); - sb.AppendLine($"{item.Subtotal:N0}"); - } - - var receiptHtml = "" + - $"Hóa đơn - {od.Id.ToString()[..8].ToUpper()}" + - "" + - "
aPOS POS
" + - "
Hệ thống quản lý bán hàng thông minh
" + - "
" + - $"
Mã đơn: {od.Id.ToString()[..8].ToUpper()}
" + - $"
Ngày: {od.CreatedAt:dd/MM/yyyy} — {od.CreatedAt:HH:mm:ss}
" + - $"
Thanh toán: {payLabel}
" + - "
" + - "" + - sb.ToString() + - "
Sản phẩmSLĐ.GiáT.Tiền
" + - $"
TỔNG CỘNG{od.TotalAmount:N0}₫
" + - "
" + - "
Cảm ơn quý khách! Hẹn gặp lại
" + - "
Powered by aPOS Platform
" + - "" + - ""; - - await JS.InvokeVoidAsync("printPosReceipt", receiptHtml); + ShopName = _shopName ?? "Cửa hàng", + ShopAddress = _shopAddress, + ShopPhone = _shopPhone, + OrderNumber = od.Id.ToString()[..8].ToUpper(), + OrderDate = od.CreatedAt, + Items = items, + Subtotal = items.Sum(i => i.Qty * i.Price), + Total = od.TotalAmount, + PaymentMethod = payLabel, + TransactionId = od.Id.ToString()[..8].ToUpper() + }); } private async Task PrintSessionOrderDetail() @@ -1248,32 +1211,19 @@ if (_selectedOrder == null) return; var so = _selectedOrder; - var receiptHtml = "" + - $"Hóa đơn - {System.Net.WebUtility.HtmlEncode(so.Id)}" + - "" + - "
aPOS POS
" + - "
Hệ thống quản lý bán hàng thông minh
" + - "
" + - $"
Mã đơn: {System.Net.WebUtility.HtmlEncode(so.Id)}
" + - $"
Thời gian: {System.Net.WebUtility.HtmlEncode(so.Time)}
" + - $"
Thanh toán: {System.Net.WebUtility.HtmlEncode(so.Method)}
" + - "
" + - $"
{System.Net.WebUtility.HtmlEncode(so.Items)}
" + - "
" + - $"
TỔNG CỘNG{so.Total:N0}₫
" + - "
" + - "
Cảm ơn quý khách! Hẹn gặp lại
" + - "
Powered by aPOS Platform
" + - "" + - ""; - - await JS.InvokeVoidAsync("printPosReceipt", receiptHtml); + await ReceiptPrint.PrintAsync(ShopId, new ReceiptData + { + ShopName = _shopName ?? "Cửa hàng", + ShopAddress = _shopAddress, + ShopPhone = _shopPhone, + OrderNumber = so.Id, + OrderDate = DateTime.Now, + Items = new(), // session order doesn't have item detail + Subtotal = so.Total, + Total = so.Total, + PaymentMethod = so.Method, + TransactionId = so.Id + }); } // ═══════════════ DASHBOARD TAB — API-driven ═══════════════ diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs index c47ac999..8e125bf4 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri(new U // EN: Add POS data service for BFF API calls // VI: Thêm POS data service cho BFF API calls builder.Services.AddScoped(); +builder.Services.AddScoped(); // EN: Add auth state service for role-based redirects // VI: Thêm auth state service cho điều hướng theo vai trò diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ReceiptPrintService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ReceiptPrintService.cs new file mode 100644 index 00000000..79bba90d --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ReceiptPrintService.cs @@ -0,0 +1,224 @@ +// EN: Service to generate receipt HTML from saved templates and print via browser popup. +// VI: Service tạo HTML hoá đơn từ mẫu đã lưu và in qua popup trình duyệt. + +using System.Text; +using System.Text.Json; +using Microsoft.JSInterop; + +namespace WebClientTpos.Client.Services; + +/// +/// EN: Receipt data passed from POS page to the print service. +/// VI: Dữ liệu hoá đơn truyền từ POS page đến service in. +/// +public record ReceiptData +{ + public string ShopName { get; init; } = "Cửa hàng"; + public string? ShopAddress { get; init; } + public string? ShopPhone { get; init; } + public string? TaxId { get; init; } + public string OrderNumber { get; init; } = ""; + public DateTime OrderDate { get; init; } = DateTime.Now; + public string? StaffName { get; init; } + public List Items { get; init; } = new(); + public decimal Subtotal { get; init; } + public decimal DiscountAmount { get; init; } + public decimal ServiceCharge { get; init; } + public decimal VatAmount { get; init; } + public decimal Total { get; init; } + public string PaymentMethod { get; init; } = "Tiền mặt"; + public decimal? AmountTendered { get; init; } + public decimal? ChangeAmount { get; init; } + public string? TransactionId { get; init; } +} + +public record ReceiptItem(string Name, int Qty, decimal Price); + +/// +/// EN: Generates receipt HTML using saved templates from localStorage. +/// VI: Tạo HTML hoá đơn sử dụng mẫu đã lưu từ localStorage. +/// +public class ReceiptPrintService +{ + private readonly IJSRuntime _js; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public ReceiptPrintService(IJSRuntime js) => _js = js; + + /// + /// EN: Print receipt using default template for the given shop. + /// VI: In hoá đơn sử dụng mẫu mặc định của cửa hàng. + /// + public async Task PrintAsync(Guid shopId, ReceiptData data) + { + var template = await LoadDefaultTemplateAsync(shopId); + var html = BuildReceiptHtml(template, data); + await _js.InvokeVoidAsync("printPosReceipt", html); + } + + /// + /// EN: Load default receipt template from localStorage for a shop. + /// VI: Tải mẫu hoá đơn mặc định từ localStorage cho cửa hàng. + /// + private async Task LoadDefaultTemplateAsync(Guid shopId) + { + try + { + var key = $"aPOS_receipt_templates_{shopId}"; + var json = await _js.InvokeAsync("localStorage.getItem", key); + if (!string.IsNullOrWhiteSpace(json)) + { + var templates = JsonSerializer.Deserialize>(json, _jsonOptions); + var def = templates?.FirstOrDefault(t => t.IsDefault) ?? templates?.FirstOrDefault(); + if (def != null) return def; + } + } + catch { /* fallback to default */ } + + return new ReceiptTemplateSettings(); + } + + /// + /// EN: Build complete receipt HTML from template settings and order data. + /// VI: Tạo HTML hoá đơn hoàn chỉnh từ cài đặt mẫu và dữ liệu đơn hàng. + /// + private static string BuildReceiptHtml(ReceiptTemplateSettings t, ReceiptData d) + { + var paperMm = t.PaperWidth == "58mm" ? 58 : 80; + var contentW = paperMm - 8; // 4mm margin each side + var fontSize = t.FontSize switch { "small" => "11px", "large" => "15px", _ => "13px" }; + var titleSize = t.FontSize switch { "small" => "14px", "large" => "18px", _ => "16px" }; + + var sb = new StringBuilder(); + sb.Append(""); + sb.Append($"Hóa đơn - {Enc(d.OrderNumber)}"); + sb.Append(""); + + // ── HEADER ── + if (t.ShowLogo) + { + sb.Append($"
"); + sb.Append(Enc(string.IsNullOrWhiteSpace(t.HeaderText) ? d.ShopName : t.HeaderText)); + sb.Append("
"); + } + if (t.ShowAddress && !string.IsNullOrEmpty(d.ShopAddress)) + sb.Append($"
{Enc(d.ShopAddress)}
"); + if (t.ShowPhone && !string.IsNullOrEmpty(d.ShopPhone)) + sb.Append($"
ĐT: {Enc(d.ShopPhone)}
"); + if (t.ShowTaxId && !string.IsNullOrEmpty(d.TaxId)) + sb.Append($"
MST: {Enc(d.TaxId)}
"); + + sb.Append("
"); + + // ── ORDER INFO ── + if (t.ShowOrderNumber) + sb.Append($"
Đơn #:{Enc(d.OrderNumber)}
"); + if (t.ShowDateTime) + sb.Append($"
Ngày:{d.OrderDate:dd/MM/yyyy HH:mm}
"); + if (t.ShowStaffName && !string.IsNullOrEmpty(d.StaffName)) + sb.Append($"
NV:{Enc(d.StaffName)}
"); + + sb.Append("
"); + + // ── ITEMS ── + if (t.ShowItemList) + { + sb.Append(""); + foreach (var item in d.Items) + { + sb.Append($""); + sb.Append($""); + sb.Append($""); + } + sb.Append("
Sản phẩmSLTiền
{Enc(item.Name)}x{item.Qty}{item.Qty * item.Price:N0}
"); + sb.Append("
"); + } + + // ── TOTALS ── + if (t.ShowSubtotal) + sb.Append($"
Tạm tính:{d.Subtotal:N0}
"); + if (t.ShowDiscount && d.DiscountAmount > 0) + sb.Append($"
Giảm giá:-{d.DiscountAmount:N0}
"); + if (t.ShowServiceCharge && d.ServiceCharge > 0) + sb.Append($"
Phí dịch vụ:{d.ServiceCharge:N0}
"); + if (t.ShowVat && d.VatAmount > 0) + sb.Append($"
VAT:{d.VatAmount:N0}
"); + if (t.ShowTotal) + sb.Append($"
TỔNG CỘNG:{d.Total:N0}₫
"); + + sb.Append("
"); + + // ── PAYMENT ── + if (t.ShowPaymentMethod) + sb.Append($"
Thanh toán:{Enc(d.PaymentMethod)}
"); + if (t.ShowChangeAmount && d.AmountTendered.HasValue) + { + sb.Append($"
Tiền khách đưa:{d.AmountTendered.Value:N0}
"); + if (d.ChangeAmount.HasValue) + sb.Append($"
Tiền thừa:{d.ChangeAmount.Value:N0}
"); + } + if (t.ShowTransactionId && !string.IsNullOrEmpty(d.TransactionId)) + sb.Append($"
Mã GD:{Enc(d.TransactionId)}
"); + + // ── FOOTER ── + if (t.ShowBarcode && !string.IsNullOrEmpty(d.OrderNumber)) + sb.Append($"
|||{Enc(d.OrderNumber)}|||
"); + if (t.ShowWifiPassword && !string.IsNullOrEmpty(t.WifiPassword)) + sb.Append($"
WiFi: {Enc(t.WifiPassword)}
"); + if (!string.IsNullOrEmpty(t.FooterText)) + sb.Append($"
{Enc(t.FooterText)}
"); + sb.Append("
Powered by aPOS
"); + + sb.Append(""); + sb.Append(""); + + return sb.ToString(); + } + + private static string Enc(string? s) => System.Net.WebUtility.HtmlEncode(s ?? ""); + + /// + /// EN: Template settings model — matches ReceiptTemplates.razor localStorage structure. + /// VI: Mô hình cài đặt mẫu — khớp cấu trúc localStorage của ReceiptTemplates.razor. + /// + private class ReceiptTemplateSettings + { + public bool IsDefault { get; set; } = true; + 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; } + } +}