mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(billing): 80mm thermal invoice layout (ADD-P001)
GET billing/invoices/:id/document?format=thermal renders a dedicated 80mm receipt template (72mm printable, 4mm margins each side), not a CSS variant of the A4 layout — the A4 CSS is absolutely-positioned/fixed-px, tuned for a 210mm page, and doesn't reflow at thermal width. No seal (not a thermal convention, renders badly on 1-bit thermal heads); line items stack (description, then qty x rate = amount) instead of a table, since a real table leaves ~10-14 chars for description at this width. PdfRenderService gains a thermal render path: full 80mm-width viewport, content height measured via page.evaluate after settle (continuous-roll receipts have no fixed page length), and a noFallback option — a Chromium failure throws a clear error instead of silently degrading to the generic A4/no-QR fallback, which would hand back a different document than what was asked for. The frontend surfaces that as a toast pointing at the existing A4 download. format is strictly validated (a4|thermal only, BadRequestException otherwise), not silently coerced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -113,9 +114,19 @@ export class BillingController {
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
|
||||
})
|
||||
async document(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("format") format: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (format !== undefined && format !== "a4" && format !== "thermal") {
|
||||
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
|
||||
}
|
||||
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -927,6 +927,7 @@ describe("BillingService.document", () => {
|
||||
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
@@ -934,7 +935,7 @@ describe("BillingService.document", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ render } as never,
|
||||
{ render, renderThermal } as never,
|
||||
{} as never,
|
||||
{
|
||||
get: (key: string) =>
|
||||
@@ -943,7 +944,7 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
);
|
||||
return { service, render };
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||
@@ -1000,4 +1001,24 @@ describe("BillingService.document", () => {
|
||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
expect(renderThermal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1", "thermal");
|
||||
|
||||
expect(renderThermal).toHaveBeenCalledTimes(1);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,12 +411,20 @@ export class BillingService {
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||
* boundary check, not a business rule.
|
||||
*/
|
||||
async document(
|
||||
id: string,
|
||||
format: "a4" | "thermal" = "a4",
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
await this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||
return format === "thermal"
|
||||
? this.invoiceDocuments.renderThermal(model)
|
||||
: this.invoiceDocuments.render(model);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
|
||||
@@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain('class="seal"');
|
||||
expect(html).not.toContain("seal-image");
|
||||
});
|
||||
|
||||
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
|
||||
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||
expect(html).toContain('class="qr"');
|
||||
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||
expect(html).not.toContain("position: absolute");
|
||||
});
|
||||
|
||||
it("wraps a long IRN summary value rather than truncating it", () => {
|
||||
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
|
||||
expect(html).toContain(irn);
|
||||
expect(html).toContain("overflow-wrap: anywhere");
|
||||
});
|
||||
|
||||
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
|
||||
const html = service.buildThermalHtml(
|
||||
model({
|
||||
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).not.toContain("<td");
|
||||
expect(html).toContain("40ft container rail freight");
|
||||
expect(html).toContain("12 x");
|
||||
expect(html).toContain("2,948,207.4 Birr (ETB)");
|
||||
});
|
||||
|
||||
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain("width: 330px");
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,27 @@ export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
*/
|
||||
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||
|
||||
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
|
||||
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
|
||||
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
|
||||
|
||||
function esc(value: unknown): string {
|
||||
return String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function money(amount: unknown, currency: string): string {
|
||||
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
@@ -129,6 +150,117 @@ export class InvoiceDocumentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
|
||||
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
|
||||
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
|
||||
*
|
||||
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
|
||||
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
|
||||
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
|
||||
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
|
||||
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
|
||||
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
|
||||
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
|
||||
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
|
||||
*/
|
||||
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const logoImageUrl =
|
||||
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
|
||||
// Seal deliberately dropped — never fetched, so no stampSettings call either.
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
|
||||
|
||||
const html = this.buildThermalHtml(resolvedModel);
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} thermal invoice`,
|
||||
thermal: true,
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
|
||||
noFallback: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
buildThermalHtml(model: InvoiceDocumentModel): string {
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map(
|
||||
(row) =>
|
||||
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const itemBlocks = model.lines
|
||||
.map((item) => {
|
||||
const currency = item.currency ?? model.currency;
|
||||
return `<div class="item">
|
||||
<div class="item-desc">${esc(item.description)}</div>
|
||||
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(heading)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
|
||||
.doc { width: 100%; box-sizing: border-box; }
|
||||
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
|
||||
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
|
||||
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
|
||||
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
|
||||
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
|
||||
.row .label { color: #64748b; white-space: nowrap; }
|
||||
.row .value { text-align: right; overflow-wrap: anywhere; }
|
||||
.item { margin: 4px 0; }
|
||||
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
|
||||
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr img { width: 150px; height: 150px; }
|
||||
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
${logoInner}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<div class="title">${esc(heading)}</div>
|
||||
<div class="meta">${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}</div>
|
||||
<div class="rule"></div>
|
||||
${summaryRows}
|
||||
<div class="rule"></div>
|
||||
${itemBlocks}
|
||||
<div class="rule"></div>
|
||||
${totalRows}
|
||||
${qrMarkup}
|
||||
<div class="footer">Thank you</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
@@ -250,18 +382,7 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
@@ -292,7 +413,7 @@ export class InvoiceDocumentService {
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
|
||||
@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
|
||||
}
|
||||
</style>`;
|
||||
|
||||
/**
|
||||
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
||||
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
||||
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
||||
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
||||
*/
|
||||
const THERMAL_PAGE_WIDTH_MM = 80;
|
||||
const THERMAL_MARGIN_MM = 4;
|
||||
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
||||
const THERMAL_FEED_MM = 6;
|
||||
/** Guard against a runaway line-item list producing an absurd page. */
|
||||
const THERMAL_MAX_HEIGHT_MM = 1500;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||
landscape?: boolean;
|
||||
/**
|
||||
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
||||
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
||||
* `format: "A4"`.
|
||||
*/
|
||||
thermal?: boolean;
|
||||
/**
|
||||
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
||||
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
||||
* output" (it silently hands back a different document shape than what was asked for); the
|
||||
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
||||
* also supplied — an explicit fallback always wins.
|
||||
*/
|
||||
noFallback?: boolean;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
||||
* produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
@@ -54,17 +82,31 @@ export class PdfRenderService {
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
const thermal = opts.thermal ?? false;
|
||||
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
const pdf = thermal
|
||||
? await page.pdf({
|
||||
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: `${THERMAL_MARGIN_MM}mm`,
|
||||
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
||||
left: `${THERMAL_MARGIN_MM}mm`,
|
||||
right: `${THERMAL_MARGIN_MM}mm`,
|
||||
},
|
||||
})
|
||||
: await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
@@ -79,6 +121,15 @@ export class PdfRenderService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
if (!opts.fallback && opts.noFallback) {
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — it silently hands back a different document than what was asked for.
|
||||
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
||||
throw new InternalServerErrorException(
|
||||
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
||||
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
||||
);
|
||||
}
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
@@ -92,6 +143,20 @@ export class PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
||||
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
||||
* receipt, not a fixed A4-length page with blank space at the bottom.
|
||||
*/
|
||||
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
||||
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
||||
// isn't a known global to type-check against — the string is evaluated in the page's own
|
||||
// browser context regardless, same as the closure form would be.
|
||||
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
||||
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -145,6 +147,7 @@ export default function InvoiceDetailPage() {
|
||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = useQuery(
|
||||
@@ -154,12 +157,27 @@ export default function InvoiceDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadDocument = async () => {
|
||||
const downloadDocument = async (format?: "a4" | "thermal") => {
|
||||
if (!id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
const { data } = await invoicesService.downloadDocument(id, format);
|
||||
const suffix = format === "thermal" ? "-thermal" : "";
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
|
||||
} catch (error) {
|
||||
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
|
||||
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
|
||||
// let it become a silent unhandled rejection with just a spinner stopping.
|
||||
toast({
|
||||
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
|
||||
description:
|
||||
format === "thermal"
|
||||
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -202,17 +220,34 @@ export default function InvoiceDetailPage() {
|
||||
subtitle={humanize(invoice.source)}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => void downloadDocument("a4")}
|
||||
>
|
||||
Download PDF (A4)
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => void downloadDocument("thermal")}
|
||||
>
|
||||
Download thermal invoice (80mm)
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ export const invoicesService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
downloadDocument(id: string) {
|
||||
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
|
||||
downloadDocument(id: string, format?: "a4" | "thermal") {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
params: format ? { format } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user