Files
app/src/routes/admin/offers-pdf.ts
BOHA 7b20c47937 feat(pdf): drop the prices-excl.-VAT notice from offer/order/PO PDFs
The 'Ceny jsou uvedeny bez DPH...' explanatory line is removed at user request from all three non-tax-document PDFs - the 'Celkem bez DPH' total label carries the information by itself. Dead vat_notice keys and .vat-note CSS removed; tests now pin the notice's ABSENCE (file renamed pdf-vat-note -> pdf-no-vat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:19:08 +02:00

768 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
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 non-breaking space thousands separator */
function formatNum(n: number, decimals: number): 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, "\u00A0");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case "EUR":
return `${formatNum(n, 2)} \u20AC`;
case "USD":
return `$${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
case "CZK":
return `${formatNum(n, 2)} K\u010D`;
case "GBP":
return `\u00A3${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
default:
return `${formatNum(n, 2)} ${currency}`;
}
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
const allowedTags =
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
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 (defense-in-depth,
// matching the invoices/orders copies — DOMPurify runs first).
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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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;
}
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
): AddressResult {
if (!entity) return { name: "", lines: [] };
const nameKey = isSupplier ? "company_name" : "name";
const name = String(entity[nameKey] || "");
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
// Legacy PascalCase key compat
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: "name",
CompanyName: "company_name",
Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
};
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || "", entity.postal_code || ""]
.filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id)
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
cfData.forEach((cf, i) => {
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length > 0) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
valid_until: { EN: "Valid until", CZ: "Platnost do" },
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
supplier: { EN: "Supplier", CZ: "Dodavatel" },
no: { EN: "N.", CZ: "\u010C." },
description: { EN: "Description", CZ: "Popis" },
qty: { EN: "Qty", CZ: "Mn." },
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" },
of: { EN: "of", CZ: "z" },
};
export default async function offersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
},
});
if (!quotation) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
}
const settings = await prisma.company_settings.findFirst();
const isCzech = (quotation.language ?? "EN") !== "EN";
const langKey = isCzech ? "CZ" : "EN";
const currency = quotation.currency || "EUR";
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
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";
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
}
// Offers are NOT tax documents — the total is NET only (no VAT math).
const items = quotation.quotation_items;
let total = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
hasScopeContent = true;
break;
}
}
const cust = buildAddressLines(
quotation.customers as unknown as Record<string, unknown>,
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
);
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Indentation CSS for Quill
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`;
}
let itemsHtml = "";
items.forEach((item, i) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
// No Mezisoučet/VAT rows — they would only duplicate the net total.
const totalsHtml = `<div class="grand">
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>`;
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
if (hasScopeContent) {
scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div>
<hr class="separator" />`;
for (const section of quotation.scope_sections) {
const title =
isCzech && (section.title_cz || "").trim()
? section.title_cz
: section.title || "";
const content = (section.content || "").trim();
if (!title && !content) continue;
scopeHtml += '<div class="scope-section">';
if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t("of"));
const html = `<!DOCTYPE html>
<html lang="${isCzech ? "cs" : "en"}">
<head>
<meta charset="utf-8" />
<title>${quotationNumber}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
<link rel="shortcut icon" href="/favicon.ico">
<style>
/* ---- Base ---- */
@page {
size: A4;
margin: 15mm 15mm 25mm 15mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 180mm;
}
img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes v PDF vynuceno Tahoma ---- */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
/* ---- Quill indentation ---- */
${indentCSS}
/* ---- Page header ---- */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4mm;
}
.page-header .left { flex: 1; }
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
.page-title {
font-size: 18pt;
font-weight: bold;
color: #1a1a1a;
margin: 0;
}
.scope-page .page-title { font-size: 16pt; }
.quotation-number {
font-size: 12pt;
color: #1a1a1a;
margin: 1mm 0;
}
.project-code {
font-size: 10pt;
color: #646464;
}
.valid-until {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.scope-subtitle {
font-size: 11pt;
color: #646464;
margin-top: 1mm;
}
.scope-description {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.separator {
border: none;
border-top: 0.5pt solid #e0e0e0;
margin: 3mm 0 5mm 0;
}
/* ---- Addresses ---- */
.addresses {
display: flex;
justify-content: space-between;
margin-bottom: 8mm;
}
.address-block { width: 48%; }
.address-block.right { text-align: right; }
.address-label {
font-size: 9pt;
font-weight: bold;
color: #646464;
line-height: 1.5;
}
.address-name {
font-size: 9pt;
font-weight: bold;
color: #1a1a1a;
line-height: 1.5;
}
.address-line {
font-size: 9pt;
color: #646464;
line-height: 1.5;
}
/* ---- Items table ---- */
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8pt;
font-weight: 600;
color: #646464;
padding: 6px 8px;
text-align: left;
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 1pt solid #1a1a1a;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 7px 8px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 8pt;
}
table.items tbody td.desc {
font-size: 10pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.total-cell {
font-weight: 700;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
font-weight: 400;
}
/* ---- Totals ---- */
.totals-wrapper {
display: flex;
justify-content: flex-end;
break-inside: avoid;
margin-top: 8mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 8.5pt;
color: #646464;
margin-bottom: 2mm;
}
.totals .row:last-child { margin-bottom: 0; }
.totals .row .value {
color: #1a1a1a;
font-size: 8.5pt;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 9.5pt;
font-weight: 400;
color: #646464;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
/* ---- Scope sections ---- */
.scope-page {
page-break-before: always;
}
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 1mm;
}
.section-content {
font-size: 9pt;
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.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; }
/* ---- Repeating page header ---- */
table.page-layout {
width: 100%;
border-collapse: collapse;
}
table.page-layout > thead > tr > td,
table.page-layout > tbody > tr > td {
padding: 0;
border: none;
vertical-align: top;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
@bottom-center {
content: "${pageLabel} " counter(page) " ${ofLabel} " counter(pages);
font-size: 8pt;
color: #969696;
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
}
}
}
/* ---- Screen-only: A4 page preview ---- */
@media screen {
html {
background: #525659;
}
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.quotation-page, .scope-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
table.page-layout,
table.page-layout > thead,
table.page-layout > thead > tr,
table.page-layout > thead > tr > td,
table.page-layout > tbody,
table.page-layout > tbody > tr,
table.page-layout > tbody > tr > td {
display: block;
width: 100%;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div>
<hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses">
<div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</div>
<div class="address-block right">
<div class="address-label">${escapeHtml(t("supplier"))}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</div>
</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
<th style="width:44%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<div class="totals-wrapper">
<div class="totals">
${totalsHtml}
</div>
</div>
</td></tr>
</tbody>
</table>
</div>
${scopeHtml}
</body>
</html>`;
const saveMode = query.save === "1";
// Save PDF to NAS
if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at
? new Date(quotation.created_at)
: new Date();
const pdfBuffer = await htmlToPdf(html);
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
pdfBuffer,
);
return success(reply, null, 200, "PDF uloženo");
}
return reply.type("text/html").send(html);
} catch (err) {
request.log.error(err, "PDF generation failed");
return reply
.status(500)
.type("text/html")
.send(
"<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>",
);
}
},
);
}