feat(issued-orders-pdf): true per-page footer (Vystavil + Strana X z Y), sections flow inline
The PDF now carries a real repeating footer on EVERY page via Puppeteer's footerTemplate (Chromium has no CSS margin-box support, so this is the only true footer): 'Vystavil: <logged-in user>' on the left, 'Strana X z Y' / 'Page X of Y' centered, localized by the document language. htmlToPdf gained an optional footerTemplate option (empty header suppresses Chromium's default; bottom margin grows to 18mm). The old body footer pinned by flex min-height is gone. The 'Obsah' sections no longer start a new page with a repeated header - they flow inline right after the items/totals block and paginate naturally (break-inside: avoid per section). Applied at all three render sites (PDF route, ?save=1 archive, /file fallback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||
import issuedOrdersPdfRoutes, {
|
||||
renderIssuedOrderHtml,
|
||||
buildIssuedOrderPdfFooter,
|
||||
} from "../routes/admin/issued-orders-pdf";
|
||||
import { nasOrdersManager } from "../services/nas-financials-manager";
|
||||
import { htmlToPdf } from "../utils/html-to-pdf";
|
||||
@@ -793,15 +794,32 @@ describe("renderIssuedOrderHtml", () => {
|
||||
expect(en).not.toContain("Amounts are in");
|
||||
});
|
||||
|
||||
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||
it("body carries NO footer — Vystavil moved to the Puppeteer footer template", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||
expect(html).toContain("Jan Novák");
|
||||
// The issuer prints in the per-page footerTemplate, never in the body.
|
||||
expect(html).not.toContain("Jan Novák");
|
||||
expect(html).not.toContain("Vystavil:");
|
||||
expect(html).not.toContain("invoice-footer");
|
||||
expect(html).not.toContain("E-mail:");
|
||||
// The old two-column signature footer is gone.
|
||||
expect(html).not.toContain("footer-row");
|
||||
expect(html).not.toContain("Schválil:");
|
||||
});
|
||||
|
||||
it("buildIssuedOrderPdfFooter: Vystavil left, page counter centered, per language", () => {
|
||||
const cs = buildIssuedOrderPdfFooter("cs", "Jan Novák");
|
||||
expect(cs).toContain("Vystavil:");
|
||||
expect(cs).toContain("Jan Novák");
|
||||
expect(cs).toContain('Strana <span class="pageNumber"></span>');
|
||||
expect(cs).toContain('z <span class="totalPages"></span>');
|
||||
|
||||
const en = buildIssuedOrderPdfFooter("en", "Jan Novák");
|
||||
expect(en).toContain("Issued by:");
|
||||
expect(en).toContain('Page <span class="pageNumber"></span>');
|
||||
expect(en).toContain('of <span class="totalPages"></span>');
|
||||
|
||||
// Escaping: a malicious issuer name must not break out of the template.
|
||||
const evil = buildIssuedOrderPdfFooter("cs", '<img src=x onerror="1">');
|
||||
expect(evil).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -1201,7 +1219,7 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
||||
];
|
||||
const issuer = { name: "Jan Novák" };
|
||||
|
||||
it("renders the scope page: cs prefers title_cz, sanitizes content, repeats the PO header", () => {
|
||||
it("renders sections INLINE after the items (no new page, no repeated header); cs prefers title_cz, sanitizes content", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
|
||||
{
|
||||
title: "Scope EN",
|
||||
@@ -1210,7 +1228,19 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
||||
},
|
||||
{ title: "Only EN", title_cz: null, content: null },
|
||||
]);
|
||||
expect(html).toContain('class="scope-page"');
|
||||
expect(html).toContain('class="scope-sections"');
|
||||
// No separate page: no page-break wrapper, header NOT repeated
|
||||
// (<title> + the one page header = exactly 2 occurrences).
|
||||
expect(html).not.toContain("scope-page");
|
||||
expect(html).not.toContain("page-break-before");
|
||||
expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(2);
|
||||
// Sections flow INSIDE the content block, after the items/totals.
|
||||
expect(html.indexOf('class="scope-sections"')).toBeGreaterThan(
|
||||
html.indexOf("total-cell"),
|
||||
);
|
||||
expect(html.indexOf('class="scope-sections"')).toBeLessThan(
|
||||
html.indexOf("/.invoice-content"),
|
||||
);
|
||||
// cs → title_cz preferred when non-empty…
|
||||
expect(html).toContain("Rozsah CZ");
|
||||
expect(html).not.toContain("Scope EN");
|
||||
@@ -1219,9 +1249,6 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
||||
expect(html).toContain("Obsah");
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("alert(1)");
|
||||
// The PO header block repeats on the scope page: <title> + main page
|
||||
// header + scope page header = 3 occurrences (2 without the scope page).
|
||||
expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(3);
|
||||
});
|
||||
|
||||
it("uses the EN title for lang=en", () => {
|
||||
@@ -1232,15 +1259,15 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
||||
expect(html).not.toContain("Rozsah CZ");
|
||||
});
|
||||
|
||||
it("suppresses the whole page when every section is empty (tag-stripped)", () => {
|
||||
it("suppresses the sections block when every section is empty (tag-stripped)", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
|
||||
{ title: "", title_cz: " ", content: "<p><br></p>" },
|
||||
]);
|
||||
expect(html).not.toContain('class="scope-page"');
|
||||
expect(html).not.toContain('class="scope-sections"');
|
||||
});
|
||||
|
||||
it("omits the scope page when there are no sections at all", () => {
|
||||
it("omits the sections block when there are no sections at all", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
expect(html).not.toContain('class="scope-page"');
|
||||
expect(html).not.toContain('class="scope-sections"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user