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>
717 lines
21 KiB
TypeScript
717 lines
21 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import type { Prisma, company_settings } from "@prisma/client";
|
||
import prisma from "../../config/database";
|
||
import { requirePermission } from "../../middleware/auth";
|
||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||
import { parseId, success } from "../../utils/response";
|
||
import {
|
||
formatDate,
|
||
formatNum,
|
||
formatCurrency,
|
||
escapeHtml,
|
||
cleanQuillHtml,
|
||
buildQuillIndentCss,
|
||
} from "../../utils/pdf-shared";
|
||
import createDOMPurify from "dompurify";
|
||
import { JSDOM } from "jsdom";
|
||
|
||
const window = new JSDOM("").window;
|
||
const DOMPurify = createDOMPurify(window);
|
||
|
||
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" },
|
||
};
|
||
|
||
/** Quotation row with the relations the PDF template renders. */
|
||
export type OfferForPdf = Prisma.quotationsGetPayload<{
|
||
include: {
|
||
customers: true;
|
||
quotation_items: true;
|
||
scope_sections: true;
|
||
};
|
||
}>;
|
||
|
||
/**
|
||
* Build the full offer PDF/preview HTML. Extracted from the route handler so
|
||
* the offers /:id/file live-render fallback (quotations.ts) can reuse the
|
||
* exact same template without going through HTTP.
|
||
*/
|
||
export function renderOfferHtml(
|
||
quotation: OfferForPdf,
|
||
settings: company_settings | null,
|
||
): string {
|
||
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);
|
||
}
|
||
}
|
||
// Visibility uses the SAME per-language title rule as the render loop plus
|
||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||
// — otherwise a section with only the other language's title would count as
|
||
// content and produce a scope page holding nothing but the header. Mirrors
|
||
// issued-orders-pdf.
|
||
const stripSectionTags = (html: string | null | undefined): string =>
|
||
(html || "")
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /g, " ")
|
||
.trim();
|
||
const resolveSectionTitle = (s: {
|
||
title: string | null;
|
||
title_cz: string | null;
|
||
}): string =>
|
||
isCzech && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || "";
|
||
const visibleSections = quotation.scope_sections.filter(
|
||
(s) => resolveSectionTitle(s).trim() || stripSectionTags(s.content),
|
||
);
|
||
const hasScopeContent = visibleSections.length > 0;
|
||
|
||
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
|
||
const indentCSS = buildQuillIndentCss();
|
||
|
||
let itemsHtml = "";
|
||
items.forEach((item, i) => {
|
||
const qty = Number(item.quantity) || 0;
|
||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
||
// Integers render without decimals; fractional quantities keep 2 decimals
|
||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||
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(qty, qtyDecimals)}${(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 visibleSections) {
|
||
const title = resolveSectionTitle(section);
|
||
const content = (section.content || "").trim();
|
||
scopeHtml += '<div class="scope-section">';
|
||
if (title.trim())
|
||
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>`;
|
||
|
||
return html;
|
||
}
|
||
|
||
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" }, { id: "asc" }] },
|
||
},
|
||
});
|
||
|
||
if (!quotation) {
|
||
return reply
|
||
.status(404)
|
||
.type("text/html")
|
||
.send("<html><body><h1>Nabídka nenalezena</h1></body></html>");
|
||
}
|
||
|
||
const settings = await prisma.company_settings.findFirst();
|
||
const html = renderOfferHtml(quotation, settings);
|
||
|
||
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>",
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|