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>
158 lines
6.0 KiB
TypeScript
158 lines
6.0 KiB
TypeScript
import { localDateCzStr } from "./date";
|
||
|
||
/**
|
||
* Shared helpers for the four PDF routes (offers-pdf, issued-orders-pdf,
|
||
* invoices-pdf, orders-pdf). These used to be near-verbatim copies in each
|
||
* route file; the canonical variants live here:
|
||
*
|
||
* - `cleanQuillHtml` is the STRICT variant (strips javascript:/data:/vbscript:
|
||
* in BOTH href and src — defense-in-depth, DOMPurify runs first).
|
||
* - `formatNum` uses the NBSP (U+00A0) thousands separator so amounts never
|
||
* line-wrap inside the number.
|
||
* - `formatCurrency` is the per-currency-symbol variant from offers
|
||
* (€ / $ / Kč / £, anything else falls back to "<num> <code>").
|
||
*/
|
||
|
||
/** dd.MM.yyyy in Europe/Prague; invalid input echoes back as-is. */
|
||
export function formatDate(date: Date | string | null | undefined): string {
|
||
if (!date) return "";
|
||
const d = new Date(date);
|
||
if (isNaN(d.getTime())) return String(date);
|
||
return localDateCzStr(d);
|
||
}
|
||
|
||
/** Format number with comma decimal separator and NBSP thousands separator. */
|
||
export function formatNum(n: number, decimals = 2): string {
|
||
const abs = Math.abs(n);
|
||
const fixed = abs.toFixed(decimals);
|
||
const [intPart, decPart] = fixed.split(".");
|
||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||
return n < 0 ? `-${result}` : result;
|
||
}
|
||
|
||
/** Per-currency symbol formatting (offers variant). */
|
||
export function formatCurrency(amount: number, currency: string): string {
|
||
const n = Number(amount) || 0;
|
||
switch (currency) {
|
||
case "EUR":
|
||
return `${formatNum(n, 2)} €`;
|
||
case "USD":
|
||
return `$${Math.abs(n)
|
||
.toFixed(2)
|
||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||
case "CZK":
|
||
return `${formatNum(n, 2)} Kč`;
|
||
case "GBP":
|
||
return `£${Math.abs(n)
|
||
.toFixed(2)
|
||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||
default:
|
||
return `${formatNum(n, 2)} ${currency}`;
|
||
}
|
||
}
|
||
|
||
export function escapeHtml(str: string | null | undefined): string {
|
||
if (!str) return "";
|
||
return str
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||
*/
|
||
export function cleanQuillHtml(html: string | null | undefined): string {
|
||
if (!html) return "";
|
||
let s = html;
|
||
// Remove dangerous tags with content
|
||
s = s.replace(
|
||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||
"",
|
||
);
|
||
s = s.replace(
|
||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||
"",
|
||
);
|
||
// Strip event handlers
|
||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||
// Strip javascript:/data:/vbscript: in href and src
|
||
s = s.replace(
|
||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||
'href="#"',
|
||
);
|
||
s = s.replace(
|
||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||
'src=""',
|
||
);
|
||
// Replace with regular space (outside of tags)
|
||
s = s.replace(/( )/g, " ");
|
||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||
// Merge adjacent spans with same attributes
|
||
let prev = "";
|
||
while (prev !== s) {
|
||
prev = s;
|
||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||
}
|
||
return s;
|
||
}
|
||
|
||
/** CSS rules for Quill's .ql-indent-1..9 classes (identical in all templates). */
|
||
export function buildQuillIndentCss(): string {
|
||
let indentCSS = "";
|
||
for (let n = 1; n <= 9; n++) {
|
||
const pad = n * 3;
|
||
const liPad = n * 3 + 1.5;
|
||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||
}
|
||
return indentCSS;
|
||
}
|