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:
@@ -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> ${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(),
|
||||
|
||||
Reference in New Issue
Block a user