One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
808 lines
23 KiB
TypeScript
808 lines
23 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import prisma from "../../config/database";
|
||
import { requirePermission } from "../../middleware/auth";
|
||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||
import { parseId, error } from "../../utils/response";
|
||
import {
|
||
formatDate,
|
||
formatNum,
|
||
escapeHtml,
|
||
cleanQuillHtml,
|
||
buildQuillIndentCss,
|
||
} from "../../utils/pdf-shared";
|
||
import { parseBody } from "../../schemas/common";
|
||
import { z } from "zod";
|
||
import createDOMPurify from "dompurify";
|
||
import { JSDOM } from "jsdom";
|
||
|
||
const window = new JSDOM("").window;
|
||
const DOMPurify = createDOMPurify(window);
|
||
|
||
// Plain object (Zod strips unknown keys rather than rejecting) — the frontend
|
||
// may send extra item fields (id, position, item_description) we don't use.
|
||
const OrderPdfItemSchema = z.object({
|
||
description: z.string().max(8000),
|
||
quantity: z.number().min(0).finite(),
|
||
unit: z.string().max(255),
|
||
unit_price: z.number().min(0).finite(),
|
||
is_included_in_total: z.boolean().optional(),
|
||
});
|
||
|
||
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
||
// `items` is strictly validated; `lang` is typed explicitly so the handler no
|
||
// longer reads it as an untyped passthrough key.
|
||
const OrderPdfBodySchema = z.looseObject({
|
||
items: z.array(OrderPdfItemSchema).optional(),
|
||
lang: z.string().max(10).optional(),
|
||
});
|
||
|
||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||
|
||
interface AddressResult {
|
||
name: string;
|
||
lines: string[];
|
||
}
|
||
|
||
function buildAddressLines(
|
||
entity: Record<string, unknown> | null,
|
||
isSupplier: boolean,
|
||
tObj: Record<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;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = `${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 ────────────────────────────────────────────────── */
|
||
|
||
const translations: Record<string, Record<string, string>> = {
|
||
cs: {
|
||
title: "POTVRZENÍ PŘIJETÍ OBJEDNÁVKY",
|
||
supplier: "Dodavatel",
|
||
customer: "Odběratel",
|
||
order_no: "Číslo objednávky:",
|
||
po_no: "Číslo zakáz. objednávky:",
|
||
date: "Datum:",
|
||
payment_method: "Forma úhrady:",
|
||
billing: "Potvrzujeme Vám následující položky:",
|
||
col_no: "Č.",
|
||
col_desc: "Popis",
|
||
col_qty: "Množství",
|
||
col_unit_price: "Jedn. cena",
|
||
col_total: "Celkem",
|
||
total_no_vat: "Celkem bez DPH",
|
||
amounts_in: "Částky jsou uvedeny v",
|
||
notes: "Poznámky",
|
||
issued_by: "Vystavil:",
|
||
received_by: "Převzal:",
|
||
stamp: "Razítko:",
|
||
ico: "IČ: ",
|
||
dic: "DIČ: ",
|
||
},
|
||
en: {
|
||
title: "ORDER CONFIRMATION",
|
||
supplier: "Supplier",
|
||
customer: "Customer",
|
||
order_no: "Order No.:",
|
||
po_no: "PO No.:",
|
||
date: "Date:",
|
||
payment_method: "Payment method:",
|
||
billing: "We confirm the following items:",
|
||
col_no: "No.",
|
||
col_desc: "Description",
|
||
col_qty: "Quantity",
|
||
col_unit_price: "Unit price",
|
||
col_total: "Total",
|
||
total_no_vat: "Total excl. VAT",
|
||
amounts_in: "Amounts are in",
|
||
notes: "Notes",
|
||
issued_by: "Issued by:",
|
||
received_by: "Received by:",
|
||
stamp: "Stamp:",
|
||
ico: "Reg. No.: ",
|
||
dic: "Tax ID: ",
|
||
},
|
||
};
|
||
|
||
/* ── Template ────────────────────────────────────────────────────── */
|
||
|
||
export interface OrderConfirmationPdfItem {
|
||
description: string;
|
||
quantity: number;
|
||
unit: string;
|
||
unit_price: number;
|
||
is_included_in_total: boolean;
|
||
}
|
||
|
||
interface OrderConfirmationPdfData {
|
||
order_number: string | null;
|
||
customer_order_number: string | null;
|
||
created_at: Date | string | null;
|
||
currency: string | null;
|
||
notes: string | null;
|
||
// Not a real orders column today — read defensively (PHP-era data carried it).
|
||
payment_method?: string | null;
|
||
customers?: Record<string, unknown> | null;
|
||
}
|
||
|
||
/**
|
||
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
|
||
* are NET only: no VAT columns or VAT summary; the grand total is labeled
|
||
* "Celkem bez DPH". Exported for tests.
|
||
*/
|
||
export function renderOrderConfirmationHtml(
|
||
order: OrderConfirmationPdfData,
|
||
items: OrderConfirmationPdfItem[],
|
||
settings: Record<string, unknown> | null,
|
||
lang: "cs" | "en",
|
||
userName: string,
|
||
): string {
|
||
const t = translations[lang];
|
||
|
||
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" />`;
|
||
}
|
||
|
||
const currency = order.currency || "CZK";
|
||
|
||
// NET-only total over the included lines — no VAT math anywhere.
|
||
let total = 0;
|
||
for (const item of items) {
|
||
if (item.is_included_in_total) total += item.quantity * item.unit_price;
|
||
}
|
||
total = Math.round(total * 100) / 100;
|
||
|
||
const supp = buildAddressLines(settings, true, t);
|
||
const cust = buildAddressLines(
|
||
(order.customers as Record<string, unknown>) || null,
|
||
false,
|
||
t,
|
||
);
|
||
|
||
const suppLinesHtml = supp.lines
|
||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||
.join("");
|
||
const custLinesHtml = cust.lines
|
||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||
.join("");
|
||
|
||
const orderNumber = escapeHtml(order.order_number || "");
|
||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||
const orderDateStr = formatDate(order.created_at);
|
||
|
||
const itemsHtml = items
|
||
.map((item, i) => {
|
||
const lineTotal = item.quantity * item.unit_price;
|
||
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||
return `<tr>
|
||
<td class="row-num">${i + 1}</td>
|
||
<td class="desc">${escapeHtml(item.description)}</td>
|
||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||
<td class="right">${formatNum(item.unit_price)}</td>
|
||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
const paymentMethod =
|
||
String(order.payment_method || "") ||
|
||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||
|
||
const notesRaw = order.notes ?? "";
|
||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||
const notesHtml = notesStripped
|
||
? `
|
||
<div class="invoice-notes">
|
||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||
</div>
|
||
`
|
||
: "";
|
||
|
||
// Quill indent CSS
|
||
const indentCSS = buildQuillIndentCss();
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="${escapeHtml(lang)}">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>${escapeHtml(t.title)} ${orderNumber}</title>
|
||
<style>
|
||
@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.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;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="invoice-page">
|
||
<div class="invoice-content">
|
||
|
||
<!-- Hlavicka -->
|
||
<div class="invoice-header">
|
||
<div class="left">
|
||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||
</div>
|
||
<div class="invoice-title">${escapeHtml(t.title)}</div>
|
||
</div>
|
||
|
||
<!-- Dodavatel / Odberatel + Detaily -->
|
||
<table class="header-grid" cellspacing="0">
|
||
<tr>
|
||
<td>
|
||
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||
<div class="address-name">${escapeHtml(supp.name)}</div>
|
||
${suppLinesHtml}
|
||
</td>
|
||
<td class="addr-customer">
|
||
<div class="address-label">${escapeHtml(t.customer)}</div>
|
||
<div class="address-name">${escapeHtml(cust.name)}</div>
|
||
${custLinesHtml}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="details-bank">
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>
|
||
${poNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.po_no)}</span> <span class="val">${poNumber}</span></div>` : ""}
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(paymentMethod)}</span></div>
|
||
</td>
|
||
<td>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.date)}</span> <span class="val">${escapeHtml(orderDateStr)}</span></div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- Polozky -->
|
||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
||
<table class="items">
|
||
<thead>
|
||
<tr>
|
||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${itemsHtml}
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||
<div class="totals-wrapper">
|
||
<div class="totals">
|
||
<div class="grand">
|
||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||
</div>
|
||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
${notesHtml}
|
||
|
||
</div><!-- /.invoice-content -->
|
||
<div class="invoice-footer">
|
||
|
||
<!-- Vystavil -->
|
||
<div class="issued-by">
|
||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(userName)}
|
||
</div>
|
||
|
||
<!-- Prevzal / razitko -->
|
||
<div class="footer-row">
|
||
<div class="col">${escapeHtml(t.received_by)}</div>
|
||
<div class="col" style="text-align:right">${escapeHtml(t.stamp)}</div>
|
||
</div>
|
||
|
||
</div><!-- /.invoice-footer -->
|
||
</div><!-- /.invoice-page -->
|
||
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
/* ── Route ───────────────────────────────────────────────────────── */
|
||
|
||
export default async function ordersPdfRoutes(
|
||
fastify: FastifyInstance,
|
||
): Promise<void> {
|
||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||
"/:id/confirmation",
|
||
{ preHandler: requirePermission("orders.view") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||
const body = parsed.data;
|
||
|
||
try {
|
||
const lang = body.lang === "en" ? "en" : "cs";
|
||
|
||
const order = await prisma.orders.findUnique({
|
||
where: { id },
|
||
// The confirmation PDF never renders the PO attachment — don't pull
|
||
// the blob just to read the order header/items.
|
||
omit: { attachment_data: true },
|
||
include: {
|
||
customers: true,
|
||
order_items: { orderBy: { position: "asc" } },
|
||
},
|
||
});
|
||
|
||
if (!order) {
|
||
return reply
|
||
.status(404)
|
||
.type("text/html")
|
||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||
}
|
||
|
||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||
string,
|
||
unknown
|
||
> | null;
|
||
|
||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||
// a live preview of unsaved edits on the detail page) OR from the
|
||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||
// stored order is an editing action, so the custom-items path requires
|
||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||
// the STORED order items instead — preventing a `orders.view` holder
|
||
// from producing an "official" confirmation with invented figures.
|
||
const authData = request.authData;
|
||
const canUseCustomItems =
|
||
authData?.roleName === "admin" ||
|
||
!!authData?.permissions.includes("orders.edit");
|
||
const customItemsRaw =
|
||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||
|
||
let items: OrderConfirmationPdfItem[];
|
||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||
items = customItemsRaw.map((it) => ({
|
||
description: it.description,
|
||
quantity: it.quantity,
|
||
unit: it.unit,
|
||
unit_price: it.unit_price,
|
||
is_included_in_total: it.is_included_in_total !== false,
|
||
}));
|
||
} else {
|
||
items = order.order_items.map((it) => ({
|
||
description: it.description || "",
|
||
quantity: Number(it.quantity) || 0,
|
||
unit: it.unit || "",
|
||
unit_price: Number(it.unit_price) || 0,
|
||
is_included_in_total: !!it.is_included_in_total,
|
||
}));
|
||
}
|
||
|
||
const userName = request.authData
|
||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||
: "";
|
||
|
||
const html = renderOrderConfirmationHtml(
|
||
order,
|
||
items,
|
||
settings,
|
||
lang,
|
||
userName,
|
||
);
|
||
|
||
const pdfBuffer = await htmlToPdf(html);
|
||
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
|
||
|
||
return reply
|
||
.type("application/pdf")
|
||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||
.send(pdfBuffer);
|
||
} catch (err) {
|
||
request.log.error(err, "PDF generation failed");
|
||
return reply
|
||
.status(500)
|
||
.type("text/html")
|
||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||
}
|
||
},
|
||
);
|
||
}
|