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) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-29 10:08:28 +07:00
parent b666d2f68d
commit c31881f7b6
3 changed files with 281 additions and 106 deletions

View File

@@ -10,6 +10,7 @@
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject IJSRuntime JS
@inject ReceiptPrintService ReceiptPrint
@* ═══════════════ MAIN CONTENT AREA + VERTICAL NAV ═══════════════ *@
<div style="display:flex;flex:1;overflow:hidden;">
@@ -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($"<tr><td style='text-align:left;padding:3px 0;'>{System.Net.WebUtility.HtmlEncode(item.Name)}</td>");
sb.AppendLine($"<td style='text-align:center;padding:3px 4px;'>{item.Qty}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;'>{item.Price:N0}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;font-weight:600;'>{item.Qty * item.Price:N0}</td></tr>");
}
var receiptHtml = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
$"<title>Hóa đơn - {_lastTransactionId}</title>" +
"<style>" +
"@page { margin: 4mm; size: 80mm auto; }" +
"body { font-family: 'Courier New', monospace; font-size: 12px; width: 72mm; margin: 0 auto; color: #000; }" +
".c { text-align: center; } .b { font-weight: bold; }" +
".d { border-top: 1px dashed #000; margin: 6px 0; }" +
"table { width: 100%; border-collapse: collapse; }" +
"th { text-align: left; font-size: 11px; border-bottom: 1px solid #000; padding: 2px 0; }" +
".f { font-size: 10px; text-align: center; margin-top: 8px; color: #555; }" +
"</style></head><body>" +
"<div class='c b' style='font-size:16px;'>aPOS POS</div>" +
"<div class='c' style='font-size:10px;margin-bottom:4px;'>Hệ thống quản lý bán hàng thông minh</div>" +
"<div class='d'></div>" +
$"<div><b>Mã đơn:</b> {_lastTransactionId}</div>" +
$"<div><b>Ngày:</b> {now:dd/MM/yyyy} — {now:HH:mm:ss}</div>" +
$"<div><b>Thanh toán:</b> {payLabel}</div>" +
"<div class='d'></div>" +
"<table><tr><th>Sản phẩm</th><th style='text-align:center;'>SL</th><th style='text-align:right;'>Đ.Giá</th><th style='text-align:right;'>T.Tiền</th></tr>" +
sb.ToString() +
"</table><div class='d'></div>" +
$"<div style='display:flex;justify-content:space-between;font-size:14px;font-weight:bold;'><span>TỔNG CỘNG</span><span>{_lastOrderTotal:N0}₫</span></div>" +
"<div class='d'></div>" +
"<div class='f'>Cảm ơn quý khách! Hẹn gặp lại</div>" +
"<div class='f'>Powered by aPOS Platform</div>" +
"<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>" +
"</body></html>";
// 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($"<tr><td style='text-align:left;padding:3px 0;'>{System.Net.WebUtility.HtmlEncode(item.ProductName ?? "—")}</td>");
sb.AppendLine($"<td style='text-align:center;padding:3px 4px;'>{item.Quantity}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;'>{item.UnitPrice:N0}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;font-weight:600;'>{item.Subtotal:N0}</td></tr>");
}
var receiptHtml = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
$"<title>Hóa đơn - {od.Id.ToString()[..8].ToUpper()}</title>" +
"<style>" +
"@page { margin: 4mm; size: 80mm auto; }" +
"body { font-family: 'Courier New', monospace; font-size: 12px; width: 72mm; margin: 0 auto; color: #000; }" +
".c { text-align: center; } .b { font-weight: bold; }" +
".d { border-top: 1px dashed #000; margin: 6px 0; }" +
"table { width: 100%; border-collapse: collapse; }" +
"th { text-align: left; font-size: 11px; border-bottom: 1px solid #000; padding: 2px 0; }" +
".f { font-size: 10px; text-align: center; margin-top: 8px; color: #555; }" +
"</style></head><body>" +
"<div class='c b' style='font-size:16px;'>aPOS POS</div>" +
"<div class='c' style='font-size:10px;margin-bottom:4px;'>Hệ thống quản lý bán hàng thông minh</div>" +
"<div class='d'></div>" +
$"<div><b>Mã đơn:</b> {od.Id.ToString()[..8].ToUpper()}</div>" +
$"<div><b>Ngày:</b> {od.CreatedAt:dd/MM/yyyy} — {od.CreatedAt:HH:mm:ss}</div>" +
$"<div><b>Thanh toán:</b> {payLabel}</div>" +
"<div class='d'></div>" +
"<table><tr><th>Sản phẩm</th><th style='text-align:center;'>SL</th><th style='text-align:right;'>Đ.Giá</th><th style='text-align:right;'>T.Tiền</th></tr>" +
sb.ToString() +
"</table><div class='d'></div>" +
$"<div style='display:flex;justify-content:space-between;font-size:14px;font-weight:bold;'><span>TỔNG CỘNG</span><span>{od.TotalAmount:N0}₫</span></div>" +
"<div class='d'></div>" +
"<div class='f'>Cảm ơn quý khách! Hẹn gặp lại</div>" +
"<div class='f'>Powered by aPOS Platform</div>" +
"<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>" +
"</body></html>";
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 = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
$"<title>Hóa đơn - {System.Net.WebUtility.HtmlEncode(so.Id)}</title>" +
"<style>" +
"@page { margin: 4mm; size: 80mm auto; }" +
"body { font-family: 'Courier New', monospace; font-size: 12px; width: 72mm; margin: 0 auto; color: #000; }" +
".c { text-align: center; } .b { font-weight: bold; }" +
".d { border-top: 1px dashed #000; margin: 6px 0; }" +
".f { font-size: 10px; text-align: center; margin-top: 8px; color: #555; }" +
"</style></head><body>" +
"<div class='c b' style='font-size:16px;'>aPOS POS</div>" +
"<div class='c' style='font-size:10px;margin-bottom:4px;'>Hệ thống quản lý bán hàng thông minh</div>" +
"<div class='d'></div>" +
$"<div><b>Mã đơn:</b> {System.Net.WebUtility.HtmlEncode(so.Id)}</div>" +
$"<div><b>Thời gian:</b> {System.Net.WebUtility.HtmlEncode(so.Time)}</div>" +
$"<div><b>Thanh toán:</b> {System.Net.WebUtility.HtmlEncode(so.Method)}</div>" +
"<div class='d'></div>" +
$"<div style='font-size:12px;'>{System.Net.WebUtility.HtmlEncode(so.Items)}</div>" +
"<div class='d'></div>" +
$"<div style='display:flex;justify-content:space-between;font-size:14px;font-weight:bold;'><span>TỔNG CỘNG</span><span>{so.Total:N0}₫</span></div>" +
"<div class='d'></div>" +
"<div class='f'>Cảm ơn quý khách! Hẹn gặp lại</div>" +
"<div class='f'>Powered by aPOS Platform</div>" +
"<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>" +
"</body></html>";
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 ═══════════════

View File

@@ -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<WebClientTpos.Client.Services.PosDataService>();
builder.Services.AddScoped<WebClientTpos.Client.Services.ReceiptPrintService>();
// EN: Add auth state service for role-based redirects
// VI: Thêm auth state service cho điều hướng theo vai trò

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ReceiptItem> 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);
/// <summary>
/// EN: Generates receipt HTML using saved templates from localStorage.
/// VI: Tạo HTML hoá đơn sử dụng mẫu đã lưu từ localStorage.
/// </summary>
public class ReceiptPrintService
{
private readonly IJSRuntime _js;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ReceiptPrintService(IJSRuntime js) => _js = js;
/// <summary>
/// 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.
/// </summary>
public async Task PrintAsync(Guid shopId, ReceiptData data)
{
var template = await LoadDefaultTemplateAsync(shopId);
var html = BuildReceiptHtml(template, data);
await _js.InvokeVoidAsync("printPosReceipt", html);
}
/// <summary>
/// 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.
/// </summary>
private async Task<ReceiptTemplateSettings> LoadDefaultTemplateAsync(Guid shopId)
{
try
{
var key = $"aPOS_receipt_templates_{shopId}";
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
if (!string.IsNullOrWhiteSpace(json))
{
var templates = JsonSerializer.Deserialize<List<ReceiptTemplateSettings>>(json, _jsonOptions);
var def = templates?.FirstOrDefault(t => t.IsDefault) ?? templates?.FirstOrDefault();
if (def != null) return def;
}
}
catch { /* fallback to default */ }
return new ReceiptTemplateSettings();
}
/// <summary>
/// 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.
/// </summary>
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("<!DOCTYPE html><html><head><meta charset='utf-8'>");
sb.Append($"<title>Hóa đơn - {Enc(d.OrderNumber)}</title>");
sb.Append("<style>");
sb.Append($"@page {{ margin: 4mm; size: {paperMm}mm auto; }}");
sb.Append($"body {{ font-family: 'Courier New', monospace; font-size: {fontSize}; width: {contentW}mm; margin: 0 auto; color: #000; }}");
sb.Append(".c { text-align: center; } .b { font-weight: bold; }");
sb.Append(".d { border-top: 1px dashed #000; margin: 6px 0; }");
sb.Append("table { width: 100%; border-collapse: collapse; }");
sb.Append("th { text-align: left; border-bottom: 1px solid #000; padding: 2px 0; }");
sb.Append(".r { display: flex; justify-content: space-between; padding: 2px 0; }");
sb.Append(".f { text-align: center; margin-top: 8px; color: #555; }");
sb.Append("</style></head><body>");
// ── HEADER ──
if (t.ShowLogo)
{
sb.Append($"<div class='c b' style='font-size:{titleSize};'>");
sb.Append(Enc(string.IsNullOrWhiteSpace(t.HeaderText) ? d.ShopName : t.HeaderText));
sb.Append("</div>");
}
if (t.ShowAddress && !string.IsNullOrEmpty(d.ShopAddress))
sb.Append($"<div class='c' style='font-size:10px;'>{Enc(d.ShopAddress)}</div>");
if (t.ShowPhone && !string.IsNullOrEmpty(d.ShopPhone))
sb.Append($"<div class='c' style='font-size:10px;'>ĐT: {Enc(d.ShopPhone)}</div>");
if (t.ShowTaxId && !string.IsNullOrEmpty(d.TaxId))
sb.Append($"<div class='c' style='font-size:10px;'>MST: {Enc(d.TaxId)}</div>");
sb.Append("<div class='d'></div>");
// ── ORDER INFO ──
if (t.ShowOrderNumber)
sb.Append($"<div class='r'><span>Đơn #:</span><b>{Enc(d.OrderNumber)}</b></div>");
if (t.ShowDateTime)
sb.Append($"<div class='r'><span>Ngày:</span><span>{d.OrderDate:dd/MM/yyyy HH:mm}</span></div>");
if (t.ShowStaffName && !string.IsNullOrEmpty(d.StaffName))
sb.Append($"<div class='r'><span>NV:</span><span>{Enc(d.StaffName)}</span></div>");
sb.Append("<div class='d'></div>");
// ── ITEMS ──
if (t.ShowItemList)
{
sb.Append("<table><tr><th>Sản phẩm</th><th style='text-align:center;'>SL</th><th style='text-align:right;'>Tiền</th></tr>");
foreach (var item in d.Items)
{
sb.Append($"<tr><td style='padding:2px 0;'>{Enc(item.Name)}</td>");
sb.Append($"<td style='text-align:center;'>x{item.Qty}</td>");
sb.Append($"<td style='text-align:right;'>{item.Qty * item.Price:N0}</td></tr>");
}
sb.Append("</table>");
sb.Append("<div class='d'></div>");
}
// ── TOTALS ──
if (t.ShowSubtotal)
sb.Append($"<div class='r'><span>Tạm tính:</span><span>{d.Subtotal:N0}</span></div>");
if (t.ShowDiscount && d.DiscountAmount > 0)
sb.Append($"<div class='r' style='color:#c00;'><span>Giảm giá:</span><span>-{d.DiscountAmount:N0}</span></div>");
if (t.ShowServiceCharge && d.ServiceCharge > 0)
sb.Append($"<div class='r'><span>Phí dịch vụ:</span><span>{d.ServiceCharge:N0}</span></div>");
if (t.ShowVat && d.VatAmount > 0)
sb.Append($"<div class='r'><span>VAT:</span><span>{d.VatAmount:N0}</span></div>");
if (t.ShowTotal)
sb.Append($"<div class='r b' style='font-size:{titleSize};padding:4px 0;'><span>TỔNG CỘNG:</span><span>{d.Total:N0}₫</span></div>");
sb.Append("<div class='d'></div>");
// ── PAYMENT ──
if (t.ShowPaymentMethod)
sb.Append($"<div class='r'><span>Thanh toán:</span><span>{Enc(d.PaymentMethod)}</span></div>");
if (t.ShowChangeAmount && d.AmountTendered.HasValue)
{
sb.Append($"<div class='r'><span>Tiền khách đưa:</span><span>{d.AmountTendered.Value:N0}</span></div>");
if (d.ChangeAmount.HasValue)
sb.Append($"<div class='r'><span>Tiền thừa:</span><span>{d.ChangeAmount.Value:N0}</span></div>");
}
if (t.ShowTransactionId && !string.IsNullOrEmpty(d.TransactionId))
sb.Append($"<div class='r' style='font-size:10px;'><span>Mã GD:</span><span>{Enc(d.TransactionId)}</span></div>");
// ── FOOTER ──
if (t.ShowBarcode && !string.IsNullOrEmpty(d.OrderNumber))
sb.Append($"<div class='c' style='margin-top:8px;font-family:monospace;letter-spacing:4px;'>|||{Enc(d.OrderNumber)}|||</div>");
if (t.ShowWifiPassword && !string.IsNullOrEmpty(t.WifiPassword))
sb.Append($"<div class='c' style='margin-top:6px;font-size:10px;'>WiFi: {Enc(t.WifiPassword)}</div>");
if (!string.IsNullOrEmpty(t.FooterText))
sb.Append($"<div class='f'>{Enc(t.FooterText)}</div>");
sb.Append("<div class='f' style='font-size:9px;'>Powered by aPOS</div>");
sb.Append("<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>");
sb.Append("</body></html>");
return sb.ToString();
}
private static string Enc(string? s) => System.Net.WebUtility.HtmlEncode(s ?? "");
/// <summary>
/// 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.
/// </summary>
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; }
}
}