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

@@ -13,6 +13,8 @@ import {
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -232,6 +234,47 @@ const translations: Record<string, Record<string, string>> = {
},
};
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
function stripTags(html: string | null | undefined): string {
return (html || "").replace(/<[^>]*>/g, "").trim();
}
/**
* Repeating per-page footer for the invoice PDF, rendered by Puppeteer in the
* bottom page margin (mirrors buildIssuedOrderPdfFooter): "Vystavil: <name>"
* left, "Strana X z Y" / "Page X of Y" (by document language) centered.
* Styles must stay inline and the font-size explicit — Chromium defaults
* footer text to 0.
*/
export function buildInvoicePdfFooter(
lang: string,
issuerLine: string,
): string {
const t = translations[lang] || translations.cs;
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
</div>`;
}
/**
* Repeating per-page header for the invoice PDF — the unified red-accent
* family header (shared with issued orders): logo left, red
* "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page.
* The body's own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildInvoicePdfHeader(
lang: string,
logoDataUri: string,
invoiceNumber: string,
): string {
const t = translations[lang] || translations.cs;
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`);
}
/* ── Route ───────────────────────────────────────────────────────── */
export default async function invoicesPdfRoutes(
@@ -264,6 +307,11 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id },
orderBy: { position: "asc" },
});
// id tiebreak: position can repeat, keep the order deterministic.
const sections = await prisma.invoice_sections.findMany({
where: { invoice_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
@@ -300,16 +348,12 @@ export default async function invoicesPdfRoutes(
}
}
let logoImg = "";
if (settings?.logo_data) {
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";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// Logo as a data URI — used by the body header (screen/HTML view) AND
// the Puppeteer headerTemplate (templates can only load data: URLs).
const logoDataUri = logoDataUriFromSettings(settings);
const logoImg = logoDataUri
? `<img src="${logoDataUri}" class="logo" />`
: "";
const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
@@ -472,17 +516,41 @@ export default async function invoicesPdfRoutes(
}
}
const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<!-- Poznamky -->
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
// ── Sections ("Obsah") — flow INLINE right after the items/totals
// block (mirrors issued orders): no separate page, long content
// paginates naturally and the Puppeteer footer template stamps every
// page. Suppressed entirely when every section is empty (tag-stripped
// check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
// the Czech title when present; en (or a missing Czech title) falls
// back to the EN title.
const resolveSectionTitle = (s: {
title: string | null;
title_cz: string | null;
}): string =>
lang === "cs" && (s.title_cz || "").trim()
? s.title_cz || ""
: s.title || "";
// Visibility must use the SAME per-language title rule as the render
// loop — otherwise a section with only the other language's title
// would count as content and produce an empty sections block.
const visibleSections = sections.filter(
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
);
let sectionsHtml = "";
if (visibleSections.length > 0) {
sectionsHtml += '<div class="scope-sections">';
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
sectionsHtml += '<div class="scope-section">';
if (title.trim())
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
sectionsHtml += "</div>";
}
sectionsHtml += "</div>";
}
// Quill indent CSS
const indentCSS = buildQuillIndentCss();
@@ -496,7 +564,11 @@ export default async function invoicesPdfRoutes(
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
/* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the issued-order PDF. */
margin: 32mm 12mm 18mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -509,11 +581,19 @@ export default async function invoicesPdfRoutes(
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
/* Printable area with the Puppeteer header (32mm top) + footer (18mm
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
min-height: calc(297mm - 51mm);
}
.invoice-content { flex: 1 1 auto; }
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
at the page bottom; when the content overflows, break-inside moves the
whole block to the next page as one unit instead of stranding the notice
on the previous page. */
.invoice-footer {
flex-shrink: 0;
break-inside: avoid;
}
.accent { color: #de3a3a; }
@@ -725,13 +805,8 @@ export default async function invoicesPdfRoutes(
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
every page — same as the issued-order PDF; no body block. */
/* Upozorneni */
.notice {
@@ -797,32 +872,42 @@ export default async function invoicesPdfRoutes(
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
stranu (mirrors issued-orders-pdf). */
.scope-sections {
margin-top: 6mm;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
.section-content {
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
.section-content h2 { font-size: 18px; }
.section-content h3 { font-size: 16px; }
.section-content h4 { font-size: 15px; }
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -833,6 +918,10 @@ ${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
}
@media screen {
html { background: #525659; }
@@ -944,16 +1033,12 @@ ${indentCSS}
</div>
</div>
${notesHtml}
${sectionsHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ""}
</div>
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
<!-- Upozorneni -->
<div class="notice">
@@ -1018,7 +1103,20 @@ ${indentCSS}
? new Date(invoice.issue_date)
: new Date();
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
const pdfBuffer = await htmlToPdf(html);
// Per-page "Vystavil + Strana X z Y" footer (same as issued
// orders); the supplier e-mail rides along on the left (it used to
// print under the body Vystavil block).
const issuerLine = `${invoice.issued_by || ""}${
suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : ""
}`;
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildInvoicePdfHeader(
lang,
logoDataUri,
invoice.invoice_number || "",
),
footerTemplate: buildInvoicePdfFooter(lang, issuerLine),
});
nasInvoicesManager.saveIssuedPdf(
invoice.invoice_number!,
issueDate.getFullYear(),

View File

@@ -11,6 +11,8 @@ import {
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -226,13 +228,28 @@ export function buildIssuedOrderPdfFooter(
const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;">
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
</div>`;
}
/**
* Repeating per-page header for the issued-order PDF — the unified
* red-accent family header (shared with invoices): logo left, red
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
* own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildIssuedOrderPdfHeader(
lang: Lang,
logoDataUri: string,
poNumber: string,
): string {
const t = translations[lang];
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
@@ -249,17 +266,11 @@ export function renderIssuedOrderHtml(
const currency = order.currency || "CZK";
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
let logoImg = "";
if (settings?.logo_data) {
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";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
const logoDataUri = logoDataUriFromSettings(settings);
const logoImg = logoDataUri
? `<img src="${logoDataUri}" class="logo" />`
: "";
// PO direction: our company (settings) = Odběratel (buyer);
// the sklad_suppliers record = Dodavatel (supplier).
@@ -350,7 +361,11 @@ export function renderIssuedOrderHtml(
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
/* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the invoice PDF. */
margin: 32mm 12mm 18mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -621,6 +636,10 @@ ${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
}
@media screen {
html { background: #525659; }
@@ -795,6 +814,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
nasOrdersManager.saveIssuedPdf(
@@ -807,6 +831,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
}
const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;

View File

@@ -21,8 +21,10 @@ import {
import {
renderIssuedOrderHtml,
buildIssuedOrderPdfFooter,
buildIssuedOrderPdfHeader,
} from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import { contentDisposition } from "../../utils/content-disposition";
@@ -310,6 +312,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
sections,
);
const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});