feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer

Invoices now mirror the issued-orders document model:

- New invoice_sections table (CZ/EN rich-text "Obsah") edited via the
  shared SectionsEditor, printed inline right after the items on the
  PDF. Full-replace on update, same transaction as items.
- Printed notes dropped: the notes column is removed (migration merges
  existing content into internal_notes first); the form field is now
  "Interni poznamky", never printed. Legacy payloads sending notes are
  silently stripped.
- Form cleanup: Cislo faktury and Vystavil fields removed (number lives
  in the header, issued_by auto-fills); page header title restyled to
  the orders/offers pattern (number span + status chip).
- Unified per-page PDF header for the red-accent family: shared
  buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red
  rule) rendered by a Puppeteer headerTemplate on EVERY page of both
  invoices and issued orders (incl. the /file fallback render); body
  headers are print-hidden. htmlToPdf gained the headerTemplate option.
- Footer parity: invoices get the per-page "Vystavil + Strana X z Y"
  footer; the invoice bottom block (notice + QR/VAT recap + Prevzal)
  is break-inside: avoid so a page break can never split it.
- @page margins now match the template space (32mm top, 18mm bottom) -
  Chromium lays out by CSS @page margins, which also fixes issued
  orders' content running into the 18mm footer zone on full pages.

BREAKING CHANGE: invoices.notes column dropped (data merged into
internal_notes); deploy must run prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 19:02:37 +02:00
parent ffae1a4e74
commit 40a859f5e1
12 changed files with 991 additions and 548 deletions

View File

@@ -74,6 +74,15 @@ export interface HtmlToPdfOptions {
* Chromium's default date/title header.
*/
footerTemplate?: string;
/**
* Chromium headerTemplate HTML rendered in the top margin of EVERY page
* (same rules as footerTemplate: inline styles, explicit font-size; images
* must be data: URLs). When set, the top margin grows to 32mm to make room
* — the document body must NOT render its own header then, or page 1 shows
* it twice. NOTE: Chromium lays pages out by the document's CSS @page
* margins when present (they override this option) — keep them in sync.
*/
headerTemplate?: string;
}
export async function htmlToPdf(
@@ -95,16 +104,16 @@ export async function htmlToPdf(
format: "A4",
printBackground: true,
margin: {
top: "10mm",
top: options.headerTemplate ? "32mm" : "10mm",
bottom: options.footerTemplate ? "18mm" : "10mm",
left: "10mm",
right: "10mm",
},
...(options.footerTemplate
...(options.footerTemplate || options.headerTemplate
? {
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: options.footerTemplate,
headerTemplate: options.headerTemplate || "<span></span>",
footerTemplate: options.footerTemplate || "<span></span>",
}
: {}),
timeout: 15_000,

View File

@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
.replace(/"/g, "&quot;");
}
/**
* Company logo from company_settings.logo_data as a data: URI (mime sniffed
* from magic bytes). Data URIs are the ONLY image source Chromium loads in
* header/footer templates; the body templates reuse the same URI.
*/
export function logoDataUriFromSettings(
settings: Record<string, unknown> | null,
): string {
if (!settings?.logo_data) return "";
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
return `data:${escapeHtml(mime)};base64,${buf.toString("base64")}`;
}
/**
* Unified repeating per-page header for the red-accent PDF family (invoices +
* issued orders), rendered by Puppeteer in the top page margin: company logo
* left (max 22mm tall — same size as the documents' original body header),
* red bold heading right, 2pt #de3a3a rule underneath.
*
* Contract shared with the footers: inline styles only, explicit font-size
* (Chromium defaults template text to 0), images as data: URIs only, padding
* matched to the documents' 12mm @page side margins. The @page top margin
* must be 32mm to make room (htmlToPdf grows it when headerTemplate is set,
* but Chromium lays out by the CSS @page margins — keep them in sync).
*/
export function buildPdfHeaderTemplate(
logoDataUri: string,
heading: string,
): string {
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
</div>
</div>`;
}
/**
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,