";
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: in href
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace with regular space (outside of tags)
s = s.replace(/( )/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>\s*/gs, "$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record | 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).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;
}
}
}
// Legacy PascalCase key compat
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 = `${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> = {
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
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" },
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
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 {
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;
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("Nab\u00EDdka nenalezena
");
}
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 = `
`;
}
const items = quotation.quotation_items;
let subtotal = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
const applyVat = !!quotation.apply_vat;
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 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,
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record,
true,
t,
);
const custLinesHtml = cust.lines
.map((l) => `${escapeHtml(l)}
`)
.join("");
const suppLinesHtml = supp.lines
.map((l) => `${escapeHtml(l)}
`)
.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 += `
| ${i + 1} |
${escapeHtml(item.description)}${subDesc ? ` ${escapeHtml(subDesc)} ` : ""} |
${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""} |
${formatCurrency(Number(item.unit_price) || 0, currency)} |
${formatCurrency(lineTotal, currency)} |
`;
});
let totalsHtml = "";
if (applyVat) {
totalsHtml += `
${escapeHtml(t("subtotal"))}:
${formatCurrency(subtotal, currency)}
${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):
${formatCurrency(vatAmount, currency)}
`;
}
totalsHtml += `
${escapeHtml(t("total_to_pay"))}
${formatCurrency(totalToPay, currency)}
`;
if (exchangeRate > 0) {
totalsHtml += `${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}
`;
}
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
if (hasScopeContent) {
scopeHtml += '';
scopeHtml += `
`;
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 += '
';
if (title)
scopeHtml += `
${escapeHtml(title)}
`;
if (content)
scopeHtml += `
${cleanQuillHtml(DOMPurify.sanitize(content))}
`;
scopeHtml += "
";
}
scopeHtml += "
";
}
const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t("of"));
const html = `
${quotationNumber}
|
|
${escapeHtml(t("customer"))}
${escapeHtml(cust.name)}
${custLinesHtml}
${escapeHtml(t("supplier"))}
${escapeHtml(supp.name)}
${suppLinesHtml}
| ${escapeHtml(t("no"))} |
${escapeHtml(t("description"))} |
${escapeHtml(t("qty"))} |
${escapeHtml(t("unit_price"))} |
${escapeHtml(t("total"))} |
${itemsHtml}
|
${scopeHtml}
`;
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 reply.send({ success: true, message: "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(
"Chyba p\u0159i generov\u00E1n\u00ED PDF
",
);
}
},
);
}