- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
125 lines
3.2 KiB
TypeScript
125 lines
3.2 KiB
TypeScript
/**
|
||
* Small pdfkit helpers shared by the printable records (Stockkarte,
|
||
* Bestandsbuch): dates, empty values and a table that repeats its header on
|
||
* every page.
|
||
*/
|
||
|
||
export const PDF_MARGIN = 36;
|
||
|
||
export interface PdfColumn {
|
||
header: string;
|
||
width: number;
|
||
/** Numbers read better right-aligned; the default is left. */
|
||
align?: 'left' | 'right';
|
||
}
|
||
|
||
export interface DrawTableOptions {
|
||
/** Draws the last row in bold, e.g. a totals row. */
|
||
boldLastRow?: boolean;
|
||
}
|
||
|
||
export function formatDay(date: Date): string {
|
||
return date.toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
timeZone: 'UTC',
|
||
});
|
||
}
|
||
|
||
export function orDash(value: string | number | null | undefined): string {
|
||
return value === null || value === undefined || value === ''
|
||
? '–'
|
||
: String(value);
|
||
}
|
||
|
||
/** Widens the last column so the table fills the page width. */
|
||
export function fillWidth(
|
||
doc: PDFKit.PDFDocument,
|
||
columns: PdfColumn[],
|
||
): PdfColumn[] {
|
||
const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
|
||
const last = columns[columns.length - 1];
|
||
return [
|
||
...columns.slice(0, -1),
|
||
{ ...last, width: doc.page.width - 2 * PDF_MARGIN - fixed },
|
||
];
|
||
}
|
||
|
||
export function drawSection(doc: PDFKit.PDFDocument, title: string): void {
|
||
if (doc.y > doc.page.height - PDF_MARGIN - 80) doc.addPage();
|
||
doc.moveDown(0.6);
|
||
doc
|
||
.font('Helvetica-Bold')
|
||
.fontSize(11)
|
||
.fillColor('#000000')
|
||
.text(title, PDF_MARGIN);
|
||
doc.moveDown(0.2);
|
||
}
|
||
|
||
/** Draws a simple table, repeating the header on every new page. */
|
||
export function drawTable(
|
||
doc: PDFKit.PDFDocument,
|
||
columns: PdfColumn[],
|
||
rows: string[][],
|
||
options: DrawTableOptions = {},
|
||
): void {
|
||
const left = PDF_MARGIN;
|
||
const bottomLimit = doc.page.height - PDF_MARGIN - 20;
|
||
|
||
const drawRow = (cells: string[], isHeader: boolean, bold = isHeader) => {
|
||
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
|
||
const heights = cells.map((cell, index) =>
|
||
doc.heightOfString(cell, { width: columns[index].width - 4 }),
|
||
);
|
||
const rowHeight = Math.max(...heights, 10) + 4;
|
||
if (doc.y + rowHeight > bottomLimit) {
|
||
doc.addPage();
|
||
if (!isHeader) {
|
||
drawRow(
|
||
columns.map((c) => c.header),
|
||
true,
|
||
);
|
||
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
|
||
}
|
||
}
|
||
const top = doc.y;
|
||
let x = left;
|
||
cells.forEach((cell, index) => {
|
||
doc.text(cell, x + 2, top + 2, {
|
||
width: columns[index].width - 4,
|
||
align: columns[index].align ?? 'left',
|
||
});
|
||
x += columns[index].width;
|
||
});
|
||
doc
|
||
.moveTo(left, top + rowHeight)
|
||
.lineTo(x, top + rowHeight)
|
||
.lineWidth(0.3)
|
||
.strokeColor('#999999')
|
||
.stroke();
|
||
doc.y = top + rowHeight;
|
||
};
|
||
|
||
drawRow(
|
||
columns.map((c) => c.header),
|
||
true,
|
||
);
|
||
if (rows.length === 0) {
|
||
doc
|
||
.font('Helvetica-Oblique')
|
||
.fontSize(8)
|
||
.text('Keine Einträge.', left + 2, doc.y + 2);
|
||
doc.moveDown(0.5);
|
||
return;
|
||
}
|
||
rows.forEach((row, index) =>
|
||
drawRow(
|
||
row,
|
||
false,
|
||
options.boldLastRow === true && index === rows.length - 1,
|
||
),
|
||
);
|
||
doc.moveDown(0.5);
|
||
}
|