From 162f9b9fdfccefe322da1644a8e3ebad826e6b05 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 13:27:12 +0200 Subject: [PATCH] feat(orders): issued-order PDF on the shared invoice/confirmation template Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/issued-orders.test.ts | 14 +- src/routes/admin/issued-orders-pdf.ts | 851 ++++++++++++++++++++++---- 2 files changed, 726 insertions(+), 139 deletions(-) diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index d9e7ec3..ae99e26 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -256,7 +256,7 @@ describe("renderIssuedOrderHtml", () => { const items = [ { description: "Materiál", - item_description: null, + item_description: "Detailní popis položky", quantity: 2, unit: "ks", unit_price: 100, @@ -264,18 +264,24 @@ describe("renderIssuedOrderHtml", () => { }, ]; - it("renders the PO number, items, and both party names", () => { + it("renders the PO number, items, and both party names (PO direction)", () => { const html = renderIssuedOrderHtml( order, items, - { name: "Dodavatel" }, + { name: "Dodavatel s.r.o." }, { company_name: "Naše firma" }, "cs", ); expect(html).toContain("26720001"); expect(html).toContain("Materiál"); - expect(html).toContain("Dodavatel"); + // Optional item sub-line. + expect(html).toContain("Detailní popis položky"); + // PO direction: the customer record is the Dodavatel (supplier), our + // company is the Odběratel (buyer). + expect(html).toContain("Dodavatel s.r.o."); expect(html).toContain("Naše firma"); + expect(html).toContain("Dodavatel"); + expect(html).toContain("Odběratel"); }); it("strips script tags from notes", () => { diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index f553acb..bf69654 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -10,23 +10,33 @@ import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); -function escapeHtml(s: unknown): string { - return String(s ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */ + +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); } function formatNum(n: number, decimals = 2): string { - return new Intl.NumberFormat("cs-CZ", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }).format(n); + const abs = Math.abs(n); + const fixed = abs.toFixed(decimals); + const [intPart, decPart] = fixed.split("."); + const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); + const result = decPart ? `${withSep},${decPart}` : withSep; + return n < 0 ? `-${result}` : result; +} + +function escapeHtml(str: string | null | undefined): string { + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } -// Copied from invoices-pdf.ts: strips scripts/handlers/inline styles from Quill HTML. function cleanQuillHtml(html: string | null | undefined): string { if (!html) return ""; let s = html; @@ -40,62 +50,175 @@ function cleanQuillHtml(html: string | null | undefined): string { ); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); - 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=""', - ); + s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/( )/g, " "); + s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); + let prev = ""; + while (prev !== s) { + prev = s; + s = s.replace(/]*)>(.*?)<\/span>\s*/gs, "$2"); + } return s; } +interface AddressResult { + name: string; + lines: string[]; +} + +function buildAddressLines( + entity: Record | null, + isCompany: boolean, + tObj: Record, +): AddressResult { + if (!entity) return { name: "", lines: [] }; + + const nameKey = isCompany ? "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).fields) { + cfData = + ((parsed as Record).fields as typeof cfData) || []; + fieldOrder = ((parsed as Record).field_order || + (parsed as Record).fieldOrder) as string[] | null; + } else if (Array.isArray(parsed)) { + cfData = parsed; + } + } + } + + if (Array.isArray(fieldOrder)) { + const legacyMap: Record = { + 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 = {}; + 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 = `${tObj.ico}${entity.company_id}`; + if (entity.vat_id) fieldMap.vat_id = `${tObj.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) { + 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 }; +} + +/* ── Translations ────────────────────────────────────────────────── */ + type Lang = "cs" | "en"; -const T = { +const translations: Record> = { cs: { - title: "Objednávka", + title: "OBJEDNÁVKA", + heading: "OBJEDNÁVKA č.", + // PO direction: WE (company) are the buyer (Odběratel), the + // selected customer record is the supplier (Dodavatel). supplier: "Dodavatel", - recipient: "Odběratel", - desc: "Popis", - qty: "Množství", - unitPrice: "Cena/MJ", - base: "Bez DPH", - vat: "DPH", - lineTotal: "Celkem", - subtotal: "Základ", - vatTotal: "DPH celkem", + buyer: "Odběratel", + issue_date: "Datum vystavení:", + delivery_date: "Požadované dodání:", + billing: "Objednáváme u Vás:", + col_no: "Č.", + col_desc: "Popis", + col_qty: "Množství", + col_unit_price: "Jedn. cena", + col_price: "Cena", + col_vat_pct: "%DPH", + col_vat: "DPH", + col_total: "Celkem", + subtotal: "Mezisoučet:", + vat_label: "DPH", total: "Celkem", - orderDate: "Datum vystavení", - deliveryDate: "Požadované dodání", - notes: "Poznámka", - deliveryTerms: "Dodací podmínky", - paymentTerms: "Platební podmínky", - issuedBy: "Vystavil", + amounts_in: "Částky jsou uvedeny v", + notes: "Poznámky", + delivery_terms: "Dodací podmínky:", + payment_terms: "Platební podmínky:", + issued_by: "Vystavil:", + approved_by: "Schválil:", + ico: "IČ: ", + dic: "DIČ: ", }, en: { - title: "Purchase Order", + title: "PURCHASE ORDER", + heading: "PURCHASE ORDER No.", supplier: "Supplier", - recipient: "Buyer", - desc: "Description", - qty: "Quantity", - unitPrice: "Unit price", - base: "Net", - vat: "VAT", - lineTotal: "Total", - subtotal: "Subtotal", - vatTotal: "VAT total", + buyer: "Buyer", + issue_date: "Issue date:", + delivery_date: "Requested delivery:", + billing: "We order from you:", + col_no: "No.", + col_desc: "Description", + col_qty: "Quantity", + col_unit_price: "Unit price", + col_price: "Price", + col_vat_pct: "VAT%", + col_vat: "VAT", + col_total: "Total", + subtotal: "Subtotal:", + vat_label: "VAT", total: "Total", - orderDate: "Order date", - deliveryDate: "Requested delivery", - notes: "Note", - deliveryTerms: "Delivery terms", - paymentTerms: "Payment terms", - issuedBy: "Issued by", + amounts_in: "Amounts are in", + notes: "Notes", + delivery_terms: "Delivery terms:", + payment_terms: "Payment terms:", + issued_by: "Issued by:", + approved_by: "Approved by:", + ico: "Reg. No.: ", + dic: "Tax ID: ", }, -} as const; +}; interface IssuedOrderPdfData { po_number: string | null; @@ -126,114 +249,572 @@ export function renderIssuedOrderHtml( settings: Record | null, lang: Lang, ): string { - const t = T[lang]; + const t = translations[lang]; const applyVat = order.apply_vat !== false; - const currency = escapeHtml(order.currency || "CZK"); + const currency = order.currency || "CZK"; const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21; + 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 = ``; + } + + // PO direction: our company (settings) = Odběratel (buyer); + // the customer record = Dodavatel (supplier). + const buyer = buildAddressLines(settings, true, t); // company → Odběratel + const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel + + const buyerLinesHtml = buyer.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); + const supplierLinesHtml = supplier.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); + + // Items — NET + per-line VAT-on-top, rounded per line (same math the + // service computeIssuedOrderTotals uses; mirrors the confirmation loop). let subtotal = 0; - let vatTotal = 0; - const rows = items - .map((it) => { + let totalVat = 0; + const vatSummary: Record = {}; + const itemsHtml = items + .map((it, i) => { const qty = Number(it.quantity) || 0; const unitPrice = Number(it.unit_price) || 0; - const base = qty * unitPrice; const rate = it.vat_rate != null && it.vat_rate !== "" ? Number(it.vat_rate) : docRate; + const lineSubtotal = qty * unitPrice; const lineVat = applyVat - ? Math.round(base * (rate / 100) * 100) / 100 + ? Math.round(lineSubtotal * (rate / 100) * 100) / 100 : 0; - subtotal += base; - vatTotal += lineVat; + const lineTotal = lineSubtotal + lineVat; + + subtotal += lineSubtotal; + totalVat += lineVat; + const key = String(rate); + if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; + vatSummary[key].base += lineSubtotal; + vatSummary[key].vat += lineVat; + + const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; + const descHtml = `${escapeHtml(it.description)}${ + it.item_description + ? `
${escapeHtml(it.item_description)}
` + : "" + }`; return ` - ${escapeHtml(it.description)}${it.item_description ? `
${escapeHtml(it.item_description)}
` : ""} - ${formatNum(qty, Number.isInteger(qty) ? 0 : 2)}${it.unit ? " " + escapeHtml(it.unit) : ""} - ${formatNum(unitPrice)} - ${formatNum(base)} - ${applyVat ? Math.round(rate) + "%" : "0%"} - ${formatNum(lineVat)} - ${formatNum(base + lineVat)} - `; + ${i + 1} + ${descHtml} + ${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""} + ${formatNum(unitPrice)} + ${formatNum(lineSubtotal)} + ${applyVat ? Math.floor(rate) : 0}% + ${formatNum(lineVat)} + ${formatNum(lineTotal)} + `; }) .join(""); subtotal = Math.round(subtotal * 100) / 100; - vatTotal = Math.round(vatTotal * 100) / 100; - const total = Math.round((subtotal + vatTotal) * 100) / 100; + totalVat = Math.round(totalVat * 100) / 100; + const totalToPay = Math.round((subtotal + totalVat) * 100) / 100; - const company = (k: string) => escapeHtml(settings?.[k] ?? ""); - const cust = (k: string) => escapeHtml(customer?.[k] ?? ""); + let vatDetailHtml = ""; + if (applyVat) { + for (const [rate, data] of Object.entries(vatSummary)) { + if (data.vat > 0) { + vatDetailHtml += ` +
+ ${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: + ${formatNum(data.vat)} ${escapeHtml(currency)} +
`; + } + } + } const notesRaw = order.notes ?? ""; - const notesHtml = notesRaw.replace(/<[^>]*>/g, "").trim() - ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
` + const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); + const notesHtml = notesStripped + ? ` +
+
${escapeHtml(t.notes)}
+
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
+
+ ` : ""; - return ` -${escapeHtml(t.title)} ${escapeHtml(order.po_number)} + const issueDateStr = formatDate(order.order_date); + const deliveryDateStr = formatDate(order.delivery_date); + + // Order-specific terms (purchase-order addition; not on invoices). + let termsHtml = ""; + if (order.delivery_terms) { + termsHtml += `
${escapeHtml(t.delivery_terms)} ${escapeHtml(order.delivery_terms)}
`; + } + if (order.payment_terms) { + termsHtml += `
${escapeHtml(t.payment_terms)} ${escapeHtml(order.payment_terms)}
`; + } + const termsBlock = termsHtml ? `
${termsHtml}
` : ""; + + // Quill indent CSS + 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`; + } + + return ` + + + +${escapeHtml(t.heading)} ${poNumber} -

${escapeHtml(t.title)} ${escapeHtml(order.po_number)}

-
${escapeHtml(t.orderDate)}: ${order.order_date ? localDateCzStr(order.order_date) : "—"}${order.delivery_date ? `  ·  ${escapeHtml(t.deliveryDate)}: ${localDateCzStr(order.delivery_date)}` : ""}
-
-
-
${escapeHtml(t.supplier)}
-
${cust("name")}
-
${cust("street")}
-
${cust("postal_code")} ${cust("city")}
- ${cust("company_id") ? `
IČO: ${cust("company_id")}
` : ""} - ${cust("vat_id") ? `
DIČ: ${cust("vat_id")}
` : ""} + @page { + size: A4; + margin: 8mm 12mm 10mm 12mm; + } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { + font-family: "Segoe UI", Tahoma, Arial, sans-serif; + font-size: 10pt; + color: #1a1a1a; + width: 186mm; + } + + .invoice-page { + display: flex; + flex-direction: column; + min-height: calc(297mm - 27mm); + } + .invoice-content { flex: 1 1 auto; } + .invoice-footer { + flex-shrink: 0; + } + + .accent { color: #de3a3a; } + + /* ── Hlavicka ── */ + .invoice-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1mm; + padding-bottom: 1mm; + border-bottom: 2pt solid #de3a3a; + } + .invoice-header .left { + display: flex; + align-items: center; + gap: 3mm; + } + .logo-header { text-align: left; } + .company-title { + font-size: 12pt; + font-weight: 700; + } + .invoice-title { + font-size: 13pt; + font-weight: 700; + color: #de3a3a; + text-align: right; + letter-spacing: 0.03em; + } + + .logo { + max-width: 42mm; + max-height: 22mm; + object-fit: contain; + } + + /* ── Adresy ── */ + .header-grid { + border: 0.5pt solid #d0d0d0; + border-collapse: collapse; + width: 100%; + margin-bottom: 1mm; + } + .header-grid td { + padding: 2mm 3mm; + border: 0.5pt solid #d0d0d0; + vertical-align: top; + width: 50%; + } + .header-grid td.addr-customer { + background: #f5f5f5; + } + .header-grid td.details-bank { + background: #f5f5f5; + } + .address-label { + font-size: 8pt; + font-weight: 700; + color: #de3a3a; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 1mm; + } + .address-name { + font-size: 10pt; + font-weight: 700; + color: #1a1a1a; + line-height: 1.3; + margin-bottom: 1mm; + } + .address-line { + font-size: 9pt; + color: #444; + line-height: 1.5; + } + + /* ── Detaily (banka + datumy) — inside header-grid ── */ + + .info-row { + display: flex; + align-items: baseline; + font-size: 9pt; + padding: 1mm 0; + border-bottom: 0.5pt solid #f0f0f0; + } + .info-row:last-child { border-bottom: none; } + .info-row .lbl { + color: #666; + font-weight: 400; + flex-shrink: 0; + white-space: nowrap; + margin-right: 3mm; + } + .info-row .val { + font-weight: 600; + color: #1a1a1a; + text-align: right; + margin-left: auto; + } + + /* VS/KS blok */ + .vs-block { + font-size: 9pt; + line-height: 1.4; + padding-top: 2mm; + } + + /* ── Polozky ── */ + .billing-label { + font-weight: 700; + color: #1a1a1a; + font-size: 10pt; + padding: 2mm 0 1mm 0; + border-bottom: 1.5pt solid #de3a3a; + margin-bottom: 0; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + table.items { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + font-size: 9pt; + margin-bottom: 2mm; + } + table.items thead th { + font-size: 8.5pt; + font-weight: 600; + color: #646464; + padding: 4px 4px; + text-align: left; + border-bottom: 0.5pt solid #d0d0d0; + white-space: nowrap; + } + table.items thead th.center { text-align: center; } + table.items thead th.right { text-align: right; } + table.items tbody td { + padding: 4px 4px; + border-bottom: 0.5pt solid #e0e0e0; + vertical-align: middle; + 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: 9pt; + } + table.items tbody td.desc { + font-size: 9pt; + font-weight: 600; + color: #1a1a1a; + } + table.items tbody td.desc .item-sub { + font-weight: 400; + font-size: 8.5pt; + color: #666; + margin-top: 0.5mm; + } + table.items tbody td.total-cell { font-weight: 700; } + + /* Soucet + total - styl z nabidek */ + .totals-wrapper { + display: flex; + justify-content: flex-end; + margin-top: 2mm; + } + .totals { + width: 80mm; + } + .totals .detail-rows { + margin-bottom: 3mm; + } + .totals .row { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 9.5pt; + color: #1a1a1a; + margin-bottom: 2mm; + } + .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: 10.5pt; + font-weight: 400; + color: #1a1a1a; + align-self: center; + } + .totals .grand .value { + font-size: 14pt; + font-weight: 600; + color: #1a1a1a; + border-bottom: 2.5pt solid #de3a3a; + padding-bottom: 1mm; + } + .totals .currency-note { + text-align: right; + font-size: 8pt; + color: #1a1a1a; + margin-top: 2mm; + } + + /* Dodaci / platebni podminky (PO-specificke) */ + .terms { + margin-top: 4mm; + font-size: 9pt; + line-height: 1.5; + color: #1a1a1a; + } + .terms-row { margin-bottom: 1mm; } + .terms-row .lbl { + font-weight: 600; + color: #555; + } + + /* Vystavil */ + .issued-by { + font-size: 9pt; + margin: 2mm 0; + line-height: 1.4; + } + .issued-by .lbl { font-weight: 600; } + + /* Upozorneni */ + .notice { + font-size: 8pt; + color: #1a1a1a; + margin: 2mm 0; + line-height: 1.3; + } + + /* Prevzal / razitko */ + .footer-row { + display: flex; + justify-content: space-between; + margin-top: 4mm; + font-size: 9pt; + border-top: 0.5pt solid #aaa; + padding-top: 2mm; + min-height: 15mm; + } + .footer-row .col { + font-weight: 600; + color: #555; + } + + /* Poznamky */ + .invoice-notes { + margin-top: 4mm; + font-size: 10pt; + line-height: 1.5; + color: #1a1a1a; + } + .invoice-notes-label { + font-weight: 600; + font-size: 9pt; + text-transform: uppercase; + color: #555; + 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 * { + 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; } + + /* Quill fonty – v PDF vynuceno Tahoma */ + [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } + .ql-align-center { text-align: center; } + .ql-align-right { text-align: right; } + .ql-align-justify { text-align: justify; } +${indentCSS} + + @media print { + body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + } + @media screen { + html { background: #525659; } + body { + width: 100vw !important; + margin: 0; + padding: 30px 0; + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + overflow-x: hidden; + } + .invoice-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; + } + } + + + +
+
+ + +
+
+ ${logoImg ? `
${logoImg}
` : ""}
-
-
${escapeHtml(t.recipient)}
-
${company("company_name")}
-
${company("street")}
-
${company("postal_code")} ${company("city")}
- ${company("company_id") ? `
IČO: ${company("company_id")}
` : ""} - ${company("vat_id") ? `
DIČ: ${company("vat_id")}
` : ""} +
${escapeHtml(t.heading)} ${poNumber}
+
+ + + + + + + + + + + +
+
${escapeHtml(t.buyer)}
+
${escapeHtml(buyer.name)}
+ ${buyerLinesHtml} +
+
${escapeHtml(t.supplier)}
+
${escapeHtml(supplier.name)}
+ ${supplierLinesHtml} +
+
${escapeHtml(t.issue_date)} ${escapeHtml(issueDateStr) || "—"}
+ ${deliveryDateStr ? `
${escapeHtml(t.delivery_date)} ${escapeHtml(deliveryDateStr)}
` : ""} +
+ + +
${escapeHtml(t.billing)}
+ + + + + + + + + + + + + + + ${itemsHtml} + +
${escapeHtml(t.col_no)}${escapeHtml(t.col_desc)}${escapeHtml(t.col_qty)}${escapeHtml(t.col_unit_price)}${escapeHtml(t.col_price)}${escapeHtml(t.col_vat_pct)}${escapeHtml(t.col_vat)}${escapeHtml(t.col_total)}
+ + +
+
+
+
+ ${escapeHtml(t.subtotal)} + ${formatNum(subtotal)} ${escapeHtml(currency)} +
${vatDetailHtml} +
+
+ ${escapeHtml(t.total)} + ${formatNum(totalToPay)} ${escapeHtml(currency)} +
+
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
- - - - - - - - - - - ${rows} -
${escapeHtml(t.desc)}${escapeHtml(t.qty)}${escapeHtml(t.unitPrice)}${escapeHtml(t.base)}${escapeHtml(t.vat)}${escapeHtml(t.vat)}${escapeHtml(t.lineTotal)}
- - - - -
${escapeHtml(t.subtotal)}${formatNum(subtotal)} ${currency}
${escapeHtml(t.vatTotal)}${formatNum(vatTotal)} ${currency}
${escapeHtml(t.total)}${formatNum(total)} ${currency}
+ ${notesHtml} - ${order.delivery_terms ? `
${escapeHtml(t.deliveryTerms)}: ${escapeHtml(order.delivery_terms)}
` : ""} - ${order.payment_terms ? `
${escapeHtml(t.paymentTerms)}: ${escapeHtml(order.payment_terms)}
` : ""} - ${order.issued_by ? `
${escapeHtml(t.issuedBy)}: ${escapeHtml(order.issued_by)}
` : ""} -`; + + ${termsBlock} + +
+ +
+ + +`; } export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {