Render receipt lot labels with QR and barcode
This commit is contained in:
@ -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("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user