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,266 @@
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);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`;
}
function formatNumber(n: number): string {
return n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const LABELS: Record<string, Record<string, string>> = {
cs: {
invoice: 'Faktura',
invoice_number: 'Číslo faktury',
issue_date: 'Datum vystavení',
due_date: 'Datum splatnosti',
tax_date: 'Datum zdanitelného plnění',
payment_method: 'Způsob platby',
variable_symbol: 'Variabilní symbol',
constant_symbol: 'Konstantní symbol',
bank: 'Banka',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Číslo účtu',
supplier: 'Dodavatel',
customer: 'Odběratel',
ico: 'IČO',
dic: 'DIČ',
description: 'Popis',
qty: 'Množství',
unit: 'Jednotka',
unit_price: 'Cena/ks',
vat: 'DPH %',
total: 'Celkem',
subtotal: 'Základ',
vat_total: 'DPH',
grand_total: 'Celkem k úhradě',
paid_date: 'Datum úhrady',
issued_by: 'Vystavil',
notes: 'Poznámky',
order_number: 'Objednávka',
currency: 'Měna',
},
en: {
invoice: 'Invoice',
invoice_number: 'Invoice number',
issue_date: 'Issue date',
due_date: 'Due date',
tax_date: 'Tax date',
payment_method: 'Payment method',
variable_symbol: 'Variable symbol',
constant_symbol: 'Constant symbol',
bank: 'Bank',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Account number',
supplier: 'Supplier',
customer: 'Customer',
ico: 'Company ID',
dic: 'VAT ID',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
unit_price: 'Unit price',
vat: 'VAT %',
total: 'Total',
subtotal: 'Subtotal',
vat_total: 'VAT',
grand_total: 'Total due',
paid_date: 'Paid date',
issued_by: 'Issued by',
notes: 'Notes',
order_number: 'Order',
currency: 'Currency',
},
};
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const query = request.query as Record<string, string>;
const lang = query.lang === 'en' ? 'en' : 'cs';
const L = LABELS[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { order_number: true } },
},
});
if (!invoice) {
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
// Compute totals
const items = invoice.invoice_items.map(item => {
const qty = Number(item.quantity) || 0;
const price = Number(item.unit_price) || 0;
const vatRate = Number(item.vat_rate) || Number(invoice.vat_rate) || 21;
const lineTotal = qty * price;
const lineVat = invoice.apply_vat ? lineTotal * (vatRate / 100) : 0;
return { ...item, qty, price, vatRate, lineTotal, lineVat };
});
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0);
const vatTotal = items.reduce((s, i) => s + i.lineVat, 0);
const grandTotal = subtotal + vatTotal;
// Logo as base64
let logoHtml = '';
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';
const b64 = buf.toString('base64');
logoHtml = `<img src="data:${mime};base64,${b64}" style="max-height:60px;max-width:200px;" />`;
}
const cust = invoice.customers;
const html = `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>${L.invoice} ${escapeHtml(invoice.invoice_number)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; color: #333; padding: 20px; }
@page { size: A4; margin: 15mm; }
@media print { body { padding: 0; } .no-print { display: none; } }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; border-bottom: 2px solid #2563eb; padding-bottom: 15px; }
.header-left { flex: 1; }
.header-right { text-align: right; }
.company-name { font-size: 18px; font-weight: 700; color: #1e40af; }
.invoice-title { font-size: 22px; font-weight: 700; color: #1e40af; margin-bottom: 5px; }
.invoice-number { font-size: 14px; color: #666; }
.parties { display: flex; gap: 40px; margin: 20px 0; }
.party { flex: 1; padding: 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.party-title { font-weight: 700; font-size: 11px; text-transform: uppercase; color: #64748b; margin-bottom: 8px; letter-spacing: 0.5px; }
.party-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.meta-item { display: flex; gap: 8px; }
.meta-label { font-weight: 600; color: #64748b; min-width: 160px; }
.meta-value { color: #1e293b; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
thead th { background: #1e40af; color: white; padding: 8px 10px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; }
thead th:last-child, thead th.num { text-align: right; }
tbody td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:nth-child(even) { background: #f8fafc; }
.totals { margin-left: auto; width: 280px; margin-top: 10px; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
.totals-row.grand { font-weight: 700; font-size: 16px; color: #1e40af; border-top: 2px solid #1e40af; border-bottom: none; padding-top: 10px; }
.notes { margin-top: 20px; padding: 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; }
.notes-title { font-weight: 700; margin-bottom: 4px; }
.footer { margin-top: 30px; font-size: 10px; color: #94a3b8; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
${logoHtml}
<div class="company-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="header-right">
<div class="invoice-title">${L.invoice}</div>
<div class="invoice-number">${escapeHtml(invoice.invoice_number)}</div>
</div>
</div>
<div class="parties">
<div class="party">
<div class="party-title">${L.supplier}</div>
<div class="party-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="party">
<div class="party-title">${L.customer}</div>
<div class="party-name">${escapeHtml(cust?.name)}</div>
<div>${escapeHtml(cust?.street)}</div>
<div>${escapeHtml(cust?.city)} ${escapeHtml(cust?.postal_code)}</div>
${cust?.company_id ? `<div>${L.ico}: ${escapeHtml(cust.company_id)}</div>` : ''}
${cust?.vat_id ? `<div>${L.dic}: ${escapeHtml(cust.vat_id)}</div>` : ''}
</div>
</div>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">${L.invoice_number}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
<div class="meta-item"><span class="meta-label">${L.issue_date}:</span><span class="meta-value">${formatDate(invoice.issue_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.due_date}:</span><span class="meta-value">${formatDate(invoice.due_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.tax_date}:</span><span class="meta-value">${formatDate(invoice.tax_date)}</span></div>
${invoice.payment_method ? `<div class="meta-item"><span class="meta-label">${L.payment_method}:</span><span class="meta-value">${escapeHtml(invoice.payment_method)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.variable_symbol}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
${invoice.constant_symbol ? `<div class="meta-item"><span class="meta-label">${L.constant_symbol}:</span><span class="meta-value">${escapeHtml(invoice.constant_symbol)}</span></div>` : ''}
${invoice.bank_name ? `<div class="meta-item"><span class="meta-label">${L.bank}:</span><span class="meta-value">${escapeHtml(invoice.bank_name)}</span></div>` : ''}
${invoice.bank_iban ? `<div class="meta-item"><span class="meta-label">${L.iban}:</span><span class="meta-value">${escapeHtml(invoice.bank_iban)}</span></div>` : ''}
${invoice.bank_swift ? `<div class="meta-item"><span class="meta-label">${L.swift}:</span><span class="meta-value">${escapeHtml(invoice.bank_swift)}</span></div>` : ''}
${invoice.bank_account ? `<div class="meta-item"><span class="meta-label">${L.account}:</span><span class="meta-value">${escapeHtml(invoice.bank_account)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.currency}:</span><span class="meta-value">${escapeHtml(invoice.currency)}</span></div>
${invoice.orders?.order_number ? `<div class="meta-item"><span class="meta-label">${L.order_number}:</span><span class="meta-value">${escapeHtml(invoice.orders.order_number)}</span></div>` : ''}
${invoice.issued_by ? `<div class="meta-item"><span class="meta-label">${L.issued_by}:</span><span class="meta-value">${escapeHtml(invoice.issued_by)}</span></div>` : ''}
${invoice.paid_date ? `<div class="meta-item"><span class="meta-label">${L.paid_date}:</span><span class="meta-value">${formatDate(invoice.paid_date)}</span></div>` : ''}
</div>
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th>${L.description}</th>
<th class="num" style="width:70px;">${L.qty}</th>
<th style="width:60px;">${L.unit}</th>
<th class="num" style="width:100px;">${L.unit_price}</th>
${invoice.apply_vat ? `<th class="num" style="width:60px;">${L.vat}</th>` : ''}
<th class="num" style="width:110px;">${L.total}</th>
</tr>
</thead>
<tbody>
${items.map((item, i) => `
<tr>
<td>${i + 1}</td>
<td>${escapeHtml(item.description)}</td>
<td class="num">${formatNumber(item.qty)}</td>
<td>${escapeHtml(item.unit)}</td>
<td class="num">${formatNumber(item.price)}</td>
${invoice.apply_vat ? `<td class="num">${item.vatRate}%</td>` : ''}
<td class="num">${formatNumber(item.lineTotal)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>${L.subtotal}:</span><span>${formatNumber(subtotal)} ${invoice.currency || 'CZK'}</span></div>
${invoice.apply_vat ? `<div class="totals-row"><span>${L.vat_total}:</span><span>${formatNumber(vatTotal)} ${invoice.currency || 'CZK'}</span></div>` : ''}
<div class="totals-row grand"><span>${L.grand_total}:</span><span>${formatNumber(grandTotal)} ${invoice.currency || 'CZK'}</span></div>
</div>
${invoice.notes ? `<div class="notes"><div class="notes-title">${L.notes}:</div><div>${escapeHtml(invoice.notes)}</div></div>` : ''}
</body>
</html>`;
return reply.type('text/html').send(html);
});
}