style: run prettier on entire codebase
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** 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 [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;
|
||||
}
|
||||
@@ -22,66 +22,92 @@ function formatNum(n: number, decimals: number): string {
|
||||
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}`;
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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>';
|
||||
if (!html) return "";
|
||||
const allowedTags =
|
||||
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
|
||||
// Simple strip_tags equivalent: remove tags not in allowed list
|
||||
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, '');
|
||||
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, '');
|
||||
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(/( )/g, " ");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = '';
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
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: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
const name = String(entity[nameKey] || "");
|
||||
|
||||
// Parse custom_fields
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
|
||||
[];
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -91,29 +117,37 @@ function buildAddressLines(
|
||||
// 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',
|
||||
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);
|
||||
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();
|
||||
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}`;
|
||||
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 cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -138,172 +172,199 @@ function buildAddressLines(
|
||||
}
|
||||
|
||||
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
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' },
|
||||
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<void> {
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
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>');
|
||||
}
|
||||
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;
|
||||
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;
|
||||
|
||||
// Logo
|
||||
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" />`;
|
||||
}
|
||||
// Logo
|
||||
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" />`;
|
||||
}
|
||||
|
||||
// Calculations
|
||||
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;
|
||||
// Calculations
|
||||
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;
|
||||
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || '').trim() || (s.title || '').trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses
|
||||
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);
|
||||
// Addresses
|
||||
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('');
|
||||
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`;
|
||||
}
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// Items HTML
|
||||
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}>
|
||||
// Items HTML
|
||||
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="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>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Totals HTML
|
||||
let totalsHtml = '';
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
// Totals HTML
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t('subtotal'))}:</span>
|
||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t('vat'))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="value">${formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t('total_to_pay'))}</span>
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||
</div>`;
|
||||
if (exchangeRate > 0) {
|
||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`;
|
||||
}
|
||||
if (exchangeRate > 0) {
|
||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
|
||||
}
|
||||
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
|
||||
// Scope HTML
|
||||
let scopeHtml = '';
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
// Scope HTML
|
||||
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="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>
|
||||
${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>` : ''}
|
||||
${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(content)}</div>`;
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
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(content)}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
const pageLabel = escapeHtml(t('page'));
|
||||
const ofLabel = escapeHtml(t('of'));
|
||||
const pageLabel = escapeHtml(t("page"));
|
||||
const ofLabel = escapeHtml(t("of"));
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? 'cs' : 'en'}">
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? "cs" : "en"}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${quotationNumber}</title>
|
||||
@@ -655,22 +716,22 @@ ${indentCSS}
|
||||
<div class="first-content">
|
||||
<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">${escapeHtml(t('title'))}</div>
|
||||
<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>
|
||||
${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>
|
||||
</div>
|
||||
<hr class="separator" />
|
||||
|
||||
<div class="addresses">
|
||||
<div class="address-block left">
|
||||
<div class="address-label">${escapeHtml(t('customer'))}</div>
|
||||
<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-label">${escapeHtml(t("supplier"))}</div>
|
||||
<div class="address-name">${escapeHtml(supp.name)}</div>
|
||||
${suppLinesHtml}
|
||||
</div>
|
||||
@@ -679,11 +740,11 @@ ${indentCSS}
|
||||
<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>
|
||||
<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>
|
||||
@@ -707,11 +768,16 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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>');
|
||||
}
|
||||
});
|
||||
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>",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user