Render receipt lot labels with QR and barcode

This commit is contained in:
2026-05-29 05:38:11 +07:00
parent d0bdd4bb63
commit 2df17526c9

View File

@ -2,6 +2,8 @@
import { Printer, Tags } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import JsBarcode from "jsbarcode";
import QRCode from "qrcode";
import { useLocale } from "@/components/providers/locale-provider";
import { composeGradeLabel } from "@/lib/grade-display";
@ -264,7 +266,7 @@ export function ReceiptsClient() {
}
}
function printLotLabels(detail: ReceiptDetail) {
async function printLotLabels(detail: ReceiptDetail) {
setError(null);
if (detail.generated_lots.length === 0) {
setError(locale === "id" ? "Belum ada lot yang bisa dicetak. Buat lot terlebih dahulu." : "No lot labels to print yet. Generate lots first.");
@ -279,7 +281,29 @@ export function ReceiptsClient() {
try {
printWindow.document.open();
printWindow.document.write(buildLotLabelsPrintHtml(detail, locale));
printWindow.document.write(`<!doctype html><html><head><title>Menyiapkan Label</title></head><body style="font-family: Arial, sans-serif; padding: 24px;">${locale === "id" ? "Menyiapkan label lot..." : "Preparing lot labels..."}</body></html>`);
printWindow.document.close();
const printableLots = await Promise.all(
detail.generated_lots.map(async (lot) => {
const qrValue = lot.qr_code_value || lot.barcode_value || lot.lot_code;
const barcodeValue = lot.barcode_value || lot.qr_code_value || lot.lot_code;
return {
lot,
qrValue,
barcodeValue,
qrDataUrl: await QRCode.toDataURL(qrValue, {
width: 160,
margin: 1,
errorCorrectionLevel: "M"
}),
barcodeSvg: buildBarcodeSvg(barcodeValue)
};
})
);
printWindow.document.open();
printWindow.document.write(buildLotLabelsPrintHtml(detail, locale, printableLots));
printWindow.document.close();
} catch (err) {
if (!printWindow.closed) {
@ -452,7 +476,7 @@ export function ReceiptsClient() {
{locale === "id" ? "Cetak receipt" : "Print receipt"}
</button>
{selectedReceipt.generated_lots.length > 0 ? (
<button type="button" onClick={() => printLotLabels(selectedReceipt)} className="ops-btn-secondary">
<button type="button" onClick={() => void printLotLabels(selectedReceipt)} className="ops-btn-secondary">
<Tags className="h-4 w-4" />
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
</button>
@ -497,6 +521,23 @@ function escapeHtml(value: string) {
.replaceAll("'", "&#39;");
}
function buildBarcodeSvg(value: string) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
JsBarcode(svg, value, {
format: "CODE128",
displayValue: false,
margin: 0,
width: 1.5,
height: 44
});
svg.setAttribute("width", "100%");
svg.setAttribute("height", "52");
svg.setAttribute("preserveAspectRatio", "none");
return svg.outerHTML;
}
function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
const isIndonesian = locale === "id";
const safeReceiptNo = escapeHtml(detail.receipt_no);
@ -597,14 +638,25 @@ function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
</html>`;
}
function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
function buildLotLabelsPrintHtml(
detail: ReceiptDetail,
locale: string,
printableLots: Array<{
lot: ReceiptDetail["generated_lots"][number];
qrValue: string;
barcodeValue: string;
qrDataUrl: string;
barcodeSvg: string;
}>
) {
const isIndonesian = locale === "id";
const labels = detail.generated_lots.map((lot) => {
const labels = printableLots.map(({ lot, qrValue, barcodeValue, qrDataUrl, barcodeSvg }) => {
const safeLotCode = escapeHtml(lot.lot_code);
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
const safeWarehouse = escapeHtml(lot.warehouse.name);
const safeLocation = escapeHtml(lot.location?.name ?? "-");
const codeValue = escapeHtml(lot.qr_code_value || lot.barcode_value || lot.lot_code);
const safeQrValue = escapeHtml(qrValue);
const safeBarcodeValue = escapeHtml(barcodeValue);
return `<section class="label">
<div class="label-head">
@ -614,12 +666,21 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
</div>
<div class="status">${escapeHtml(lot.status)}</div>
</div>
<div class="barcode">${codeValue}</div>
<div class="grid">
<div><span>Grade</span><strong>${safeGrade}</strong></div>
<div><span>Qty</span><strong>${escapeHtml(formatQuantity(lot.original_qty, locale, lot.unit.code))}</strong></div>
<div><span>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
<div class="body-grid">
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR ${safeLotCode}" />
<p class="scan-value">${safeQrValue}</p>
</div>
<div class="grid">
<div><span>Grade</span><strong>${safeGrade}</strong></div>
<div><span>Qty</span><strong>${escapeHtml(formatQuantity(lot.original_qty, locale, lot.unit.code))}</strong></div>
<div><span>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
</div>
</div>
<div class="barcode">
<div class="barcode-svg">${barcodeSvg}</div>
<p class="scan-value">${safeBarcodeValue}</p>
</div>
<div class="foot">
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
@ -636,13 +697,18 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
<style>
body { margin: 0; background: #f1f5f9; color: #111827; font-family: Arial, Helvetica, sans-serif; }
.sheet { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 16px; }
.label { break-inside: avoid; background: #fff; border: 1.5px solid #111827; border-radius: 6px; padding: 14px; min-height: 190px; display: flex; flex-direction: column; gap: 12px; }
.label { break-inside: avoid; background: #fff; border: 1.5px solid #111827; border-radius: 6px; padding: 10px; min-height: 50mm; display: flex; flex-direction: column; gap: 8px; }
.label-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.eyebrow { font-size: 9px; font-weight: 800; color: #64748b; letter-spacing: 0; }
h2 { margin: 3px 0 0; font-size: 24px; letter-spacing: 0; }
h2 { margin: 3px 0 0; font-size: 18px; letter-spacing: 0; word-break: break-word; }
.status { border: 1px solid #111827; padding: 4px 7px; font-size: 10px; font-weight: 800; }
.barcode { border: 1px solid #cbd5e1; padding: 9px; text-align: center; font-family: "Courier New", monospace; font-size: 18px; font-weight: 800; letter-spacing: 2px; overflow-wrap: anywhere; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.body-grid { display: grid; grid-template-columns: 34mm 1fr; gap: 8px; }
.qr-box { border: 1px solid #cbd5e1; border-radius: 6px; padding: 5px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.qr-box img { width: 28mm; height: 28mm; }
.barcode { border: 1px solid #cbd5e1; border-radius: 6px; padding: 5px 7px; }
.barcode-svg { height: 52px; }
.scan-value { margin: 3px 0 0; text-align: center; font-size: 9px; font-weight: 800; letter-spacing: 0; word-break: break-all; }
.grid { display: grid; grid-template-columns: 1fr; gap: 6px; }
.grid div { border-top: 1px solid #e2e8f0; padding-top: 6px; }
.grid span { display: block; font-size: 9px; color: #64748b; font-weight: 800; text-transform: uppercase; }
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
@ -650,7 +716,7 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
@media print {
body { background: #fff; }
.sheet { padding: 0; gap: 8mm; }
.label { border-radius: 0; min-height: 48mm; }
.label { border-radius: 0; min-height: 50mm; }
}
</style>
</head>