Render receipt lot labels with QR and barcode
This commit is contained in:
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Printer, Tags } from "lucide-react";
|
import { Printer, Tags } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import JsBarcode from "jsbarcode";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { composeGradeLabel } from "@/lib/grade-display";
|
import { composeGradeLabel } from "@/lib/grade-display";
|
||||||
@ -264,7 +266,7 @@ export function ReceiptsClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printLotLabels(detail: ReceiptDetail) {
|
async function printLotLabels(detail: ReceiptDetail) {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (detail.generated_lots.length === 0) {
|
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.");
|
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 {
|
try {
|
||||||
printWindow.document.open();
|
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();
|
printWindow.document.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!printWindow.closed) {
|
if (!printWindow.closed) {
|
||||||
@ -452,7 +476,7 @@ export function ReceiptsClient() {
|
|||||||
{locale === "id" ? "Cetak receipt" : "Print receipt"}
|
{locale === "id" ? "Cetak receipt" : "Print receipt"}
|
||||||
</button>
|
</button>
|
||||||
{selectedReceipt.generated_lots.length > 0 ? (
|
{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" />
|
<Tags className="h-4 w-4" />
|
||||||
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
|
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
|
||||||
</button>
|
</button>
|
||||||
@ -497,6 +521,23 @@ function escapeHtml(value: string) {
|
|||||||
.replaceAll("'", "'");
|
.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) {
|
function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
|
||||||
const isIndonesian = locale === "id";
|
const isIndonesian = locale === "id";
|
||||||
const safeReceiptNo = escapeHtml(detail.receipt_no);
|
const safeReceiptNo = escapeHtml(detail.receipt_no);
|
||||||
@ -597,14 +638,25 @@ function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
</html>`;
|
</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 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 safeLotCode = escapeHtml(lot.lot_code);
|
||||||
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
|
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
|
||||||
const safeWarehouse = escapeHtml(lot.warehouse.name);
|
const safeWarehouse = escapeHtml(lot.warehouse.name);
|
||||||
const safeLocation = escapeHtml(lot.location?.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">
|
return `<section class="label">
|
||||||
<div class="label-head">
|
<div class="label-head">
|
||||||
@ -614,13 +666,22 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="status">${escapeHtml(lot.status)}</div>
|
<div class="status">${escapeHtml(lot.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="barcode">${codeValue}</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 class="grid">
|
||||||
<div><span>Grade</span><strong>${safeGrade}</strong></div>
|
<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>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 ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
|
||||||
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
|
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="barcode">
|
||||||
|
<div class="barcode-svg">${barcodeSvg}</div>
|
||||||
|
<p class="scan-value">${safeBarcodeValue}</p>
|
||||||
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
|
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
|
||||||
<span>${escapeHtml(detail.receipt_date)}</span>
|
<span>${escapeHtml(detail.receipt_date)}</span>
|
||||||
@ -636,13 +697,18 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
<style>
|
<style>
|
||||||
body { margin: 0; background: #f1f5f9; color: #111827; font-family: Arial, Helvetica, sans-serif; }
|
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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.body-grid { display: grid; grid-template-columns: 34mm 1fr; gap: 8px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, 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 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 span { display: block; font-size: 9px; color: #64748b; font-weight: 800; text-transform: uppercase; }
|
||||||
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
|
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
|
||||||
@ -650,7 +716,7 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; }
|
body { background: #fff; }
|
||||||
.sheet { padding: 0; gap: 8mm; }
|
.sheet { padding: 0; gap: 8mm; }
|
||||||
.label { border-radius: 0; min-height: 48mm; }
|
.label { border-radius: 0; min-height: 50mm; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user