- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-23 17:23:10 +02:00
parent b197017644
commit 07cb428287
36 changed files with 2233 additions and 480 deletions

View File

@@ -808,20 +808,18 @@ export default async function invoicesPdfRoutes(
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty */
.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 fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }

View File

@@ -13,6 +13,7 @@ import {
markOverdueInvoices,
listInvoices,
getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview,
getInvoiceStats,
getOrderDataForInvoice,
getInvoice,
@@ -65,7 +66,7 @@ export default async function invoicesRoutes(
"/next-number",
{ preHandler: requirePermission("invoices.create") },
async (_request, reply) => {
const result = await getNextInvoiceNumberFormatted();
const result = await getNextInvoiceNumberPreview();
return success(reply, result);
},
);

View File

@@ -381,19 +381,8 @@ export default async function offersPdfRoutes(
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 font classes v PDF vynuceno Tahoma ---- */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
@@ -606,6 +595,15 @@ ${indentCSS}
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; }

View File

@@ -0,0 +1,857 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
/* ── Helpers ─────────────────────────────────────────────────────── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): 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, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
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(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " ");
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,
tObj: Record<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) {
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;
}
}
}
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 = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.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) {
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 };
}
/* ── Translations ────────────────────────────────────────────────── */
const translations: Record<string, Record<string, string>> = {
cs: {
title: "POTVRZENÍ PŘIJETÍ OBJEDNÁVKY",
supplier: "Dodavatel",
customer: "Odběratel",
order_no: "Číslo objednávky:",
po_no: "Číslo zakáz. objednávky:",
date: "Datum:",
payment_method: "Forma úhrady:",
billing: "Potvrzujeme Vám následující položky:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
issued_by: "Vystavil:",
received_by: "Převzal:",
stamp: "Razítko:",
ico: "IČ: ",
dic: "DIČ: ",
},
en: {
title: "ORDER CONFIRMATION",
supplier: "Supplier",
customer: "Customer",
order_no: "Order No.:",
po_no: "PO No.:",
date: "Date:",
payment_method: "Payment method:",
billing: "We confirm the following items:",
col_no: "No.",
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in",
notes: "Notes",
issued_by: "Issued by:",
received_by: "Received by:",
stamp: "Stamp:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
},
};
/* ── Route ───────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body || {};
const lang = body.lang === "en" ? "en" : "cs";
const t = translations[lang];
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
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";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
const currency = order.currency || "CZK";
const applyVat = !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
// Use custom items from body if provided, otherwise order items
const customItemsRaw = body.items;
let items: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
items = customItemsRaw.map((it: Record<string, unknown>) => ({
description: String(it.description || ""),
quantity: Number(it.quantity) || 0,
unit: String(it.unit || ""),
unit_price: Number(it.unit_price) || 0,
is_included_in_total:
it.is_included_in_total !== false && it.is_included_in_total !== 0,
vat_rate: Number(it.vat_rate) || orderVatRate,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
vat_rate: orderVatRate,
}));
}
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
for (const item of items) {
if (item.is_included_in_total) {
const lineTotal = item.quantity * item.unit_price;
subtotal += lineTotal;
const rate = item.vat_rate;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineTotal;
if (applyVat) {
const lineVat = (lineTotal * rate) / 100;
vatSummary[key].vat += lineVat;
totalVat += lineVat;
}
}
}
const totalToPay = subtotal + totalVat;
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
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 orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
const itemsHtml = items
.map((item, i) => {
const lineSubtotal = item.quantity * item.unit_price;
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
</div>
`
: "";
// Quill indent CSS
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`;
}
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(t.title)} ${orderNumber}</title>
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 186mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; }
/* ── Hlavicka ── */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1mm;
padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a;
}
.invoice-header .left {
display: flex;
align-items: center;
gap: 3mm;
}
.logo-header { text-align: left; }
.company-title {
font-size: 12pt;
font-weight: 700;
}
.invoice-title {
font-size: 13pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
letter-spacing: 0.03em;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* ── Adresy ── */
.header-grid {
border: 0.5pt solid #d0d0d0;
border-collapse: collapse;
width: 100%;
margin-bottom: 1mm;
}
.header-grid td {
padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0;
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
background: #f5f5f5;
}
.header-grid td.details-bank {
background: #f5f5f5;
}
.address-label {
font-size: 8pt;
font-weight: 700;
color: #de3a3a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1mm;
}
.address-name {
font-size: 10pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 1mm;
}
.address-line {
font-size: 9pt;
color: #444;
line-height: 1.5;
}
/* ── Detaily (banka + datumy) — inside header-grid ── */
.info-row {
display: flex;
align-items: baseline;
font-size: 9pt;
padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-row .lbl {
color: #666;
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
margin-right: 3mm;
}
.info-row .val {
font-weight: 600;
color: #1a1a1a;
text-align: right;
margin-left: auto;
}
/* VS/KS blok */
.vs-block {
font-size: 9pt;
line-height: 1.4;
padding-top: 2mm;
}
/* ── Polozky ── */
.billing-label {
font-weight: 700;
color: #1a1a1a;
font-size: 10pt;
padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8.5pt;
font-weight: 600;
color: #646464;
padding: 4px 4px;
text-align: left;
border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
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: 9pt;
}
table.items tbody td.desc {
font-size: 9pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 9.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.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: 10.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 8pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 8pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* DPH rekapitulace + QR */
.recap-section {
display: flex;
gap: 5mm;
align-items: flex-start;
margin-top: 1mm;
}
.recap-section .qr {
flex-shrink: 0;
width: 28mm;
}
.recap-section .qr img,
.recap-section .qr svg { width: 28mm; height: 28mm; }
.recap-section table {
border-collapse: collapse;
font-size: 9pt;
flex: 1;
}
.recap-section table th {
font-size: 8pt;
font-weight: 600;
color: #555;
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #ccc;
}
.recap-section table td {
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #eee;
}
.recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate {
font-size: 8pt;
color: #888;
text-align: right;
border-bottom: none;
padding-top: 4px;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
justify-content: space-between;
margin-top: 4mm;
font-size: 9pt;
border-top: 0.5pt solid #aaa;
padding-top: 2mm;
min-height: 15mm;
}
.footer-row .col {
font-weight: 600;
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-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;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.title)}</div>
</div>
<!-- Dodavatel / Odberatel + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</td>
<td class="addr-customer">
<div class="address-label">${escapeHtml(t.customer)}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</td>
</tr>
<tr>
<td class="details-bank">
<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>
${poNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.po_no)}</span> <span class="val">${poNumber}</span></div>` : ""}
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(paymentMethod)}</span></div>
</td>
<td>
<div class="info-row"><span class="lbl">${escapeHtml(t.date)}</span> <span class="val">${escapeHtml(orderDateStr)}</span></div>
</td>
</tr>
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<!-- Soucty -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
${notesHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(userName)}
</div>
<!-- Prevzal / razitko -->
<div class="footer-row">
<div class="col">${escapeHtml(t.received_by)}</div>
<div class="col" style="text-align:right">${escapeHtml(t.stamp)}</div>
</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</body>
</html>`;
const pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
return reply
.type("application/pdf")
.header("Content-Disposition", `attachment; filename="${filename}"`)
.send(pdfBuffer);
},
);
}

View File

@@ -91,8 +91,11 @@ export default async function projectsRoutes(
const parsed = parseBody(UpdateProjectSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const existing = await updateProject(id, parsed.data);
if (!existing) return error(reply, "Projekt nenalezen", 404);
const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) {
return error(reply, result.error, (result as any).status ?? 400);
}
await logAudit({
request,
@@ -100,7 +103,7 @@ export default async function projectsRoutes(
action: "update",
entityType: "project",
entityId: id,
description: `Upraven projekt ${existing.name}`,
description: `Upraven projekt ${result.name}`,
});
return success(reply, { id }, 200, "Projekt byl uložen");
},

View File

@@ -278,7 +278,11 @@ export default async function quotationsRoutes(
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalidated")
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
return error(reply, "Neznámá chyba", 500);
return error(
reply,
result.error || "Neznámá chyba",
(result as any).status ?? 400,
);
}
// Keep lock — user stays on the page after save

View File

@@ -15,6 +15,11 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
const VALID_STATUSES = ["unpaid", "paid"] as const;
/** Round a monetary value to 2 decimal places to avoid floating-point drift. */
function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}
const ALLOWED_SORT_FIELDS = [
"id",
"supplier_name",
@@ -411,6 +416,15 @@ export default async function receivedInvoicesRoutes(
}
}
if (String(existing.status) === "paid") {
const attempted = Object.keys(body).filter(
(k) => !["status", "paid_date", "notes"].includes(k),
);
if (attempted.length > 0) {
return error(reply, "Nelze upravit uhrazenou fakturu", 400);
}
}
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
const finalAmount =
body.amount !== undefined
@@ -423,9 +437,9 @@ export default async function receivedInvoicesRoutes(
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
const computedVat =
finalVatRate > 0
? Math.round(
(finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100,
) / 100
? roundMoney(
finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)),
)
: 0;
// Auto-set paid_date when status transitions to paid (matching PHP)