diff --git a/app/src/app.js b/app/src/app.js index a7f03fa..60693f6 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -481,7 +481,7 @@ const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => { ].filter(Boolean).join('*'); }; -const appendInvoicePdf = async (archive, invoice) => { +const renderInvoicePdf = async (document, invoice) => { const data = invoice.invoice_data || {}; const supplier = data.supplier || {}; const customer = data.customer || {}; @@ -492,7 +492,6 @@ const appendInvoicePdf = async (archive, invoice) => { const customerName = customer.typSubjektu === 'spolecnost' ? customer.nazevSpolecnosti : [customer.jmeno, customer.prijmeni].filter(Boolean).join(' '); - const document = new PDFDocument({ size: 'A4', margin: 50 }); const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'; const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number); @@ -503,12 +502,10 @@ const appendInvoicePdf = async (archive, invoice) => { }) : null; const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right; const navy = '#183b56'; - const blue = '#2b6f8f'; const line = '#d8d4cc'; const lightBlue = '#f4f7fb'; const warm = '#fffaf0'; const bodyText = '#17202a'; - const muted = '#65717d'; const address = (profile) => [ profile.uliceCp, [profile.psc, profile.mesto].filter(Boolean).join(', ') @@ -521,7 +518,6 @@ const appendInvoicePdf = async (archive, invoice) => { const partyWidth = (pageWidth - 24) / 2; const customerX = document.page.margins.left + partyWidth + 24; - archive.append(document, { name: `${invoice.invoice_number}.pdf` }); const headerY = document.y; document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY); document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, { @@ -590,6 +586,30 @@ const appendInvoicePdf = async (archive, invoice) => { document.end(); }; +const appendInvoicePdf = async (archive, invoice) => { + const document = new PDFDocument({ size: 'A4', margin: 50 }); + archive.append(document, { name: `${invoice.invoice_number}.pdf` }); + await renderInvoicePdf(document, invoice); +}; + +app.post('/api/invoices/pdf', requireAuth, async (req, res, next) => { + try { + const data = normalizeInvoiceData(req.body.data); + if (!data) { + res.status(400).json({ error: 'Neplatná data faktury.' }); + return; + } + + const invoiceNumber = String(req.body.invoiceNumber || 'Nová faktura').trim() || 'Nová faktura'; + const document = new PDFDocument({ size: 'A4', margin: 50 }); + res.attachment(`${invoiceNumber}.pdf`); + document.pipe(res); + await renderInvoicePdf(document, { invoice_number: invoiceNumber, invoice_data: data }); + } catch (error) { + next(error); + } +}); + app.get('/api/invoices/export', requireAuth, async (req, res, next) => { try { const result = await pool.query( diff --git a/app/src/static/script.js b/app/src/static/script.js index cb2722b..e8be93b 100644 --- a/app/src/static/script.js +++ b/app/src/static/script.js @@ -630,8 +630,6 @@ const formatCzechNumber = (value) => Number(value || 0).toLocaleString("cs-CZ", maximumFractionDigits: 2 }); -const getTotalValue = () => Number.parseFloat(getValue("total").replace(",", ".")) || 0; - const updateTotal = () => { const total = Array.from(itemsContainer.querySelectorAll(".invoice-item")) .reduce((sum, item) => { @@ -643,153 +641,32 @@ const updateTotal = () => { document.getElementById("total").textContent = `${formatCzechNumber(total)} Kč`; }; -const getPersonName = (prefix) => [ - getValue(`${prefix}Jmeno`), - getValue(`${prefix}Prijmeni`) -].filter(Boolean).join(" "); +printInvoiceButton.addEventListener("click", async () => { + try { + const response = await fetch("/api/invoices/pdf", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data: readInvoiceData(), + invoiceNumber: invoiceNumberDisplay.textContent + }) + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Generování faktury se nepodařilo."); + } -const getAddress = (prefix) => [ - getValue(`${prefix}UliceCp`), - [getValue(`${prefix}Psc`), getValue(`${prefix}Mesto`)].filter(Boolean).join(" ") -].filter(Boolean).join(", "); - -const renderPerson = (prefix) => [ - ((prefix === "odberatel" && getValue("odberatelTypSubjektu") === "spolecnost") - || (prefix === "dodavatel" && getValue("dodavatelTypSubjektu") === "spolecnost")) - ? getValue(`${prefix}NazevSpolecnosti`) - : getPersonName(prefix), - getValue(`${prefix}Ico`) ? `IČO: ${getValue(`${prefix}Ico`)}` : "", - getAddress(prefix) -].filter(Boolean).map(escapeHtml).join("
"); - -const renderItems = () => Array.from(itemsContainer.querySelectorAll(".invoice-item")) - .map((item) => { - const values = Array.from(item.querySelectorAll("input")).map((input) => input.value.trim()); - return ` - ${escapeHtml(values[0])} - ${escapeHtml(values[1] ? formatCzechNumber(values[1]) : "0,00")} - ${escapeHtml(values[2])} - ${escapeHtml(values[3] ? formatCzechNumber(values[3]) : "0,00")} Kč - `; - }).join(""); - -const normalizeIban = (value) => value.replace(/\s+/g, "").toUpperCase(); - -const domesticAccountToIban = (accountNumber) => { - const match = accountNumber.replace(/\s+/g, "") - .match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/); - - if (!match) { - return ""; + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = `${invoiceNumberDisplay.textContent}.pdf`; + link.click(); + URL.revokeObjectURL(downloadUrl); + } catch (error) { + window.alert(error.message); } - - const bban = `${match[3]}${(match[1] || "").padStart(6, "0")}${match[2].padStart(10, "0")}`; - const remainder = `${bban}123500`.split("").reduce( - (value, digit) => (value * 10 + Number(digit)) % 97, - 0 - ); - const checkDigits = String(98 - remainder).padStart(2, "0"); - - return `CZ${checkDigits}${bban}`; -}; - -const buildPaymentQrPayload = (accountNumber, iban, swift, total, invoiceNumber = "") => { - const preferredAccount = normalizeIban(accountNumber); - const fallbackAccount = normalizeIban(iban); - const paymentAccount = /^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(preferredAccount) - ? preferredAccount - : domesticAccountToIban(accountNumber) || fallbackAccount; - - if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(paymentAccount)) { - return ""; - } - - return [ - "SPD*1.0", - `ACC:${paymentAccount}`, - `AM:${Number(total || 0).toFixed(2)}`, - "CC:CZK", - invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, "").slice(-10)}`, - swift && `X-SWIFT:${normalizeIban(swift)}` - ].filter(Boolean).join("*"); -}; - -const buildInvoiceHtml = () => { - const accountNumber = getValue("cisloUctu"); - const iban = getValue("iban"); - const swift = getValue("swift"); - const qrPayload = buildPaymentQrPayload( - accountNumber, - iban, - swift, - Number.parseFloat(getValue("total").replace(",", ".")) || 0, - invoiceNumberDisplay.textContent - ); - const qrMarkup = qrPayload - ? `QR kód platebních údajů` - : "

Platební QR kód vyžaduje platný IBAN. Číslo účtu bez IBANu nelze bezpečně převést.

"; - const paymentDetails = [ - accountNumber && `Číslo účtu: ${accountNumber}`, - iban && `IBAN: ${iban}`, - swift && `SWIFT: ${swift}` - ].filter(Boolean).map(escapeHtml).join("
"); - - return ` - - - - Faktura - - - -

Faktura ${escapeHtml(invoiceNumberDisplay.textContent)}

-
-

Dodavatel

${renderPerson("dodavatel") || "Neuvedeno"}
-

Odběratel

${renderPerson("odberatel") || "Neuvedeno"}
-
- - - ${renderItems() || ""} -
PoložkaMnožstvíMJCena za MJ
Žádné položky
-

Celková částka: ${formatCzechNumber(getTotalValue())} Kč

-
-

Platební údaje

${paymentDetails || "Neuvedeno"}
-
${qrMarkup}
-
- -`; -}; - -printInvoiceButton.addEventListener("click", () => { - const printWindow = window.open("about:blank", "_blank"); - - if (!printWindow) { - window.alert("Povolte prosím vyskakovací okna pro tisk faktury."); - return; - } - - printWindow.document.open(); - printWindow.document.write(buildInvoiceHtml()); - printWindow.document.close(); - printWindow.addEventListener("load", () => printWindow.print(), { once: true }); }); const addInvoiceItem = (values = {}) => {