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:
Hagernesh
2026-08-15 08:05:58 +00:00
parent 47c2e82839
commit d40c340e1c
8 changed files with 353 additions and 47 deletions

View File

@@ -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>
}
/>

View File

@@ -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,
});
},