initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View File

@@ -0,0 +1,721 @@
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 '';
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()}`;
}
/** 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 result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
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}`;
}
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 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>';
// 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, '');
// 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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, ' ');
// Merge adjacent spans with same attributes
let prev = '';
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
}
return s;
}
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] || '');
// Parse custom_fields
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') {
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' },
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);
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>');
}
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" />`;
}
// 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;
}
}
// 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('');
// 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}>
<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="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">
<div class="row">
<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="value">${formatCurrency(vatAmount, currency)}</span>
</div>
</div>`;
}
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>`;
}
// 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('scope_title'))}</div>`;
if (quotation.scope_title) {
scopeHtml += `<div class="scope-subtitle">${escapeHtml(quotation.scope_title)}</div>`;
}
if (quotation.scope_description) {
scopeHtml += `<div class="scope-description">${escapeHtml(quotation.scope_description)}</div>`;
}
scopeHtml += '</div>';
if (logoImg) {
scopeHtml += `<div class="right"><div class="logo-header">${logoImg}</div></div>`;
}
scopeHtml += `</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>';
}
const quotationNumber = escapeHtml(quotation.quotation_number);
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 ---- */
.ql-font-arial { font-family: Arial, sans-serif; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
.ql-font-courier-new { font-family: "Courier New", monospace; }
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
.ql-font-garamond { font-family: Garamond, serif; }
/* ---- 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: 500;
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;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- 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 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;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- 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%;
}
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<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>
</div>
<hr class="separator" />
<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>
</div>
</td></tr>
</tbody>
</table>
</div>
${scopeHtml}
</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>');
}
});
}