From d73eb10416fe45e94b5d6d93b80e7fd2b83f237f Mon Sep 17 00:00:00 2001 From: odweta Date: Thu, 10 Sep 2026 17:25:37 +0200 Subject: [PATCH] Add optional signature upload to invoice PDF Adds a drag-and-drop/click upload field on the invoice editor for an optional 420x210 JPG/PNG signature image. The signature is stored as part of invoice_data and rendered on the generated PDF directly above the QR code, right-aligned with sensible margins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- app/src/app.js | 11 ++++- app/src/invoice-data.js | 7 +++ app/src/static/index.html | 15 ++++++ app/src/static/script.js | 98 +++++++++++++++++++++++++++++++++++++++ app/src/static/style.css | 66 ++++++++++++++++++++++++++ app/test/app.test.js | 19 ++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) diff --git a/app/src/app.js b/app/src/app.js index 60693f6..aeaf8f5 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -10,7 +10,7 @@ const { normalizeInvoiceData } = require('./invoice-data'); const app = express(); const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; -app.use(express.json()); +app.use(express.json({ limit: '2mb' })); const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (error, derivedKey) => { @@ -500,6 +500,7 @@ const renderInvoicePdf = async (document, invoice) => { width: 180, margin: 1 }) : null; + const signatureBuffer = data.signature ? Buffer.from(data.signature.split(',')[1], 'base64') : null; const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right; const navy = '#183b56'; const line = '#d8d4cc'; @@ -573,6 +574,14 @@ const renderInvoicePdf = async (document, invoice) => { document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y) .strokeColor(navy).lineWidth(1.5).stroke(); document.y += 12; + if (signatureBuffer) { + const signatureWidth = 145; + const signatureHeight = signatureWidth / 2; + const signatureGap = 8; + const signatureX = document.page.margins.left + pageWidth - signatureWidth; + document.image(signatureBuffer, signatureX, document.y, { fit: [signatureWidth, signatureHeight] }); + document.y += signatureHeight + signatureGap; + } const paymentTop = document.y; document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line); document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10); diff --git a/app/src/invoice-data.js b/app/src/invoice-data.js index 4d0fb76..725991a 100644 --- a/app/src/invoice-data.js +++ b/app/src/invoice-data.js @@ -1,3 +1,5 @@ +const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/; + const normalizeInvoiceData = (data) => { if (!data || !Array.isArray(data.items)) { return null; @@ -16,11 +18,16 @@ const normalizeInvoiceData = (data) => { return null; } + const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature) + ? data.signature + : null; + return { supplier: data.supplier || {}, customer: data.customer || {}, payment: data.payment || {}, items, + signature, total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0) }; }; diff --git a/app/src/static/index.html b/app/src/static/index.html index 509cab9..32522c6 100644 --- a/app/src/static/index.html +++ b/app/src/static/index.html @@ -318,6 +318,21 @@ +
+
+ +
+ +

Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru

+ +
+
+ +
+

+
+
+ diff --git a/app/src/static/script.js b/app/src/static/script.js index 53b23a1..7560f8a 100644 --- a/app/src/static/script.js +++ b/app/src/static/script.js @@ -45,6 +45,12 @@ const dueDateDisplay = document.getElementById("dueDateDisplay"); const paymentPresetSelect = document.getElementById("paymentPresetSelect"); const paymentPresetName = document.getElementById("paymentPresetName"); const savePaymentPresetButton = document.getElementById("savePaymentPresetButton"); +const signatureDropzone = document.getElementById("signatureDropzone"); +const signatureInput = document.getElementById("signatureInput"); +const signaturePreview = document.getElementById("signaturePreview"); +const signatureDropzoneText = document.getElementById("signatureDropzoneText"); +const removeSignatureButton = document.getElementById("removeSignatureButton"); +const signatureStatus = document.getElementById("signatureStatus"); const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); @@ -68,6 +74,93 @@ let invoices = []; let paymentPresets = []; let currentInvoiceId = null; let isRegistrationMode = false; +let signatureDataUrl = null; + +const SIGNATURE_WIDTH = 420; +const SIGNATURE_HEIGHT = 210; + +const setSignatureStatus = (message, isError = false) => { + signatureStatus.textContent = message || ""; + signatureStatus.classList.toggle("is-error", Boolean(isError)); +}; + +const applySignaturePreview = (dataUrl) => { + signatureDataUrl = dataUrl || null; + if (signatureDataUrl) { + signaturePreview.src = signatureDataUrl; + signaturePreview.hidden = false; + signatureDropzoneText.hidden = true; + removeSignatureButton.hidden = false; + } else { + signaturePreview.hidden = true; + signaturePreview.src = ""; + signatureDropzoneText.hidden = false; + removeSignatureButton.hidden = true; + } +}; + +const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); + image.onerror = () => reject(new Error("Obrázek se nepodařilo načíst.")); + image.src = dataUrl; +}); + +const handleSignatureFile = async (file) => { + if (!file) { + return; + } + if (!["image/png", "image/jpeg"].includes(file.type)) { + setSignatureStatus("Podporovány jsou pouze soubory JPG nebo PNG.", true); + return; + } + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error("Soubor se nepodařilo načíst.")); + reader.readAsDataURL(file); + }); + const { width, height } = await readImageDimensions(dataUrl); + if (width !== SIGNATURE_WIDTH || height !== SIGNATURE_HEIGHT) { + setSignatureStatus(`Obrázek musí mít rozměry přesně ${SIGNATURE_WIDTH}×${SIGNATURE_HEIGHT} px (nahráno ${width}×${height} px).`, true); + return; + } + applySignaturePreview(dataUrl); + setSignatureStatus("Podpis byl nahrán."); + } catch (error) { + setSignatureStatus(error.message, true); + } +}; + +signatureDropzone.addEventListener("click", () => signatureInput.click()); +signatureDropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + signatureInput.click(); + } +}); +signatureDropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + signatureDropzone.classList.add("is-dragover"); +}); +signatureDropzone.addEventListener("dragleave", () => { + signatureDropzone.classList.remove("is-dragover"); +}); +signatureDropzone.addEventListener("drop", (event) => { + event.preventDefault(); + signatureDropzone.classList.remove("is-dragover"); + handleSignatureFile(event.dataTransfer.files[0]); +}); +signatureInput.addEventListener("change", () => { + handleSignatureFile(signatureInput.files[0]); + signatureInput.value = ""; +}); +removeSignatureButton.addEventListener("click", (event) => { + event.stopPropagation(); + applySignaturePreview(null); + setSignatureStatus(""); +}); const profileFieldMap = { nazevSpolecnosti: "NazevSpolecnosti", @@ -247,6 +340,7 @@ const readInvoiceData = () => ({ iban: getValue("iban"), swift: getValue("swift") }, + signature: signatureDataUrl, total: Number.parseFloat(getValue("total").replace(",", ".")) || 0, items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => { const inputs = item.querySelectorAll("input"); @@ -273,6 +367,8 @@ const clearInvoiceEditor = () => { ["cisloUctu", "iban", "swift"].forEach((id) => { document.getElementById(id).value = ""; }); + applySignaturePreview(null); + setSignatureStatus(""); itemsContainer.replaceChildren(); itemIndex = 0; updateTotal(); @@ -294,6 +390,8 @@ const loadInvoiceIntoEditor = (invoice) => { ["cisloUctu", "iban", "swift"].forEach((id) => { document.getElementById(id).value = invoice.data.payment?.[id] || ""; }); + applySignaturePreview(invoice.data.signature || null); + setSignatureStatus(""); itemsContainer.replaceChildren(); itemIndex = 0; (invoice.data.items || []).forEach((item) => addInvoiceItem(item)); diff --git a/app/src/static/style.css b/app/src/static/style.css index 5b35ae5..86a9b22 100644 --- a/app/src/static/style.css +++ b/app/src/static/style.css @@ -621,6 +621,72 @@ body { gap: 2rem; } +.signature-row { + align-items: flex-start; +} + +.signature-group { + width: 100%; + max-width: 420px; +} + +.signature-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 120px; + padding: 1rem; + text-align: center; + color: var(--muted-ink); + background: #fff; + border: 2px dashed #c8d0d5; + border-radius: 0.5rem; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.signature-dropzone:hover, +.signature-dropzone:focus-visible { + border-color: var(--blue); + outline: none; +} + +.signature-dropzone.is-dragover { + border-color: var(--blue); + background: rgba(43, 111, 143, 0.08); +} + +.signature-dropzone-text { + margin: 0; + font-size: 0.85rem; + line-height: 1.4; +} + +.signature-preview { + display: block; + max-width: 100%; + max-height: 130px; + object-fit: contain; + border-radius: 0.25rem; +} + +.signature-actions { + margin-top: 0.5rem; +} + +.signature-status { + margin: 0.4rem 0 0; + min-height: 1.1rem; + color: var(--muted-ink); + font-size: 0.8rem; +} + +.signature-status.is-error { + color: #b3261e; +} + .total-amount-group { grid-column: 1 / -1; margin: 0; diff --git a/app/test/app.test.js b/app/test/app.test.js index 1c5fd6c..38313b7 100644 --- a/app/test/app.test.js +++ b/app/test/app.test.js @@ -30,4 +30,23 @@ describe('normalizeInvoiceData', () => { { mnozstvi: 'not-a-number', cenaZaMj: 10 } ] })).toBeNull(); }); + + test('accepts a valid signature data URL and rejects invalid ones', () => { + const withValidSignature = normalizeInvoiceData({ + items: [{ mnozstvi: 1, cenaZaMj: 10 }], + signature: 'data:image/png;base64,aGVsbG8=' + }); + expect(withValidSignature.signature).toBe('data:image/png;base64,aGVsbG8='); + + const withInvalidSignature = normalizeInvoiceData({ + items: [{ mnozstvi: 1, cenaZaMj: 10 }], + signature: 'not-a-data-url' + }); + expect(withInvalidSignature.signature).toBeNull(); + + const withoutSignature = normalizeInvoiceData({ + items: [{ mnozstvi: 1, cenaZaMj: 10 }] + }); + expect(withoutSignature.signature).toBeNull(); + }); }); -- 2.54.0