diff --git a/app/src/app.js b/app/src/app.js index a7121cd..6d80740 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -5,7 +5,7 @@ const archiver = require('archiver'); const PDFDocument = require('pdfkit'); const QRCode = require('qrcode'); const { pool, initializeDatabase } = require('./db'); -const { normalizeInvoiceData } = require('./invoice-data'); +const { isValidSignatureDataUrl, normalizeInvoiceData } = require('./invoice-data'); const app = express(); const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; @@ -362,6 +362,57 @@ app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => { } }); +const signaturePresetResponse = (row) => ({ + id: row.id, + name: row.name, + dataUrl: row.data_url +}); + +app.get('/api/signature-presets', requireAuth, async (req, res, next) => { + try { + const result = await pool.query( + 'SELECT * FROM signature_presets WHERE user_id = $1 ORDER BY name', + [req.userId] + ); + res.json({ presets: result.rows.map(signaturePresetResponse) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/signature-presets', requireAuth, async (req, res, next) => { + try { + const name = String(req.body.name || '').trim(); + const dataUrl = req.body.dataUrl; + if (!name) { + res.status(400).json({ error: 'Zadejte název podpisu.' }); + return; + } + if (!isValidSignatureDataUrl(dataUrl)) { + res.status(400).json({ error: 'Podpis musí být platný obrázek JPG nebo PNG.' }); + return; + } + const result = await pool.query(` + INSERT INTO signature_presets (user_id, name, data_url) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO UPDATE SET data_url = EXCLUDED.data_url + RETURNING * + `, [req.userId, name, dataUrl]); + res.status(201).json({ preset: signaturePresetResponse(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/signature-presets/:id', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM signature_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + const invoiceResponse = (row) => ({ id: row.id, invoiceNumber: row.invoice_number, diff --git a/app/src/db.js b/app/src/db.js index 4c4804a..4789f26 100644 --- a/app/src/db.js +++ b/app/src/db.js @@ -81,6 +81,15 @@ const initializeDatabase = () => { UNIQUE (user_id, name) ); + CREATE TABLE IF NOT EXISTS signature_presets ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + data_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, name) + ); + ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; ALTER TABLE clients ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; diff --git a/app/src/invoice-data.js b/app/src/invoice-data.js index 725991a..3251953 100644 --- a/app/src/invoice-data.js +++ b/app/src/invoice-data.js @@ -1,5 +1,8 @@ const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/; +const isValidSignatureDataUrl = (value) => typeof value === 'string' + && SIGNATURE_DATA_URL_PATTERN.test(value); + const normalizeInvoiceData = (data) => { if (!data || !Array.isArray(data.items)) { return null; @@ -18,7 +21,7 @@ const normalizeInvoiceData = (data) => { return null; } - const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature) + const signature = isValidSignatureDataUrl(data.signature) ? data.signature : null; @@ -32,4 +35,4 @@ const normalizeInvoiceData = (data) => { }; }; -module.exports = { normalizeInvoiceData }; +module.exports = { isValidSignatureDataUrl, normalizeInvoiceData }; diff --git a/app/src/static/index.html b/app/src/static/index.html index 7f9c63e..4c48733 100644 --- a/app/src/static/index.html +++ b/app/src/static/index.html @@ -338,11 +338,22 @@
+
+ + +
- +

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 241f0b3..d5d2916 100644 --- a/app/src/static/script.js +++ b/app/src/static/script.js @@ -57,6 +57,10 @@ const signaturePreview = document.getElementById("signaturePreview"); const signatureDropzoneText = document.getElementById("signatureDropzoneText"); const removeSignatureButton = document.getElementById("removeSignatureButton"); const signatureStatus = document.getElementById("signatureStatus"); +const signaturePresetSelect = document.getElementById("signaturePresetSelect"); +const signaturePresetName = document.getElementById("signaturePresetName"); +const saveSignaturePresetButton = document.getElementById("saveSignaturePresetButton"); +const deleteSignaturePresetButton = document.getElementById("deleteSignaturePresetButton"); const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); @@ -78,6 +82,7 @@ let itemIndex = 0; let clients = []; let invoices = []; let paymentPresets = []; +let signaturePresets = []; let currentInvoiceId = null; let isRegistrationMode = false; let signatureDataUrl = null; @@ -99,12 +104,30 @@ const applySignaturePreview = (dataUrl) => { removeSignatureButton.hidden = false; } else { signaturePreview.hidden = true; - signaturePreview.src = ""; + signaturePreview.removeAttribute("src"); signatureDropzoneText.hidden = false; removeSignatureButton.hidden = true; } }; +const renderSignaturePresetOptions = (selectedId = signaturePresetSelect.value) => { + signaturePresetSelect.innerHTML = ""; + signaturePresets.forEach((preset) => { + const option = document.createElement("option"); + option.value = preset.id; + option.textContent = preset.name; + signaturePresetSelect.appendChild(option); + }); + signaturePresetSelect.value = selectedId; + deleteSignaturePresetButton.disabled = !signaturePresetSelect.value; +}; + +const loadSignaturePresets = async () => { + const response = await apiRequest("/api/signature-presets"); + signaturePresets = response.presets; + renderSignaturePresetOptions(); +}; + const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); @@ -162,6 +185,53 @@ signatureInput.addEventListener("change", () => { handleSignatureFile(signatureInput.files[0]); signatureInput.value = ""; }); +signaturePresetSelect.addEventListener("change", () => { + const preset = signaturePresets.find(({ id }) => String(id) === signaturePresetSelect.value); + deleteSignaturePresetButton.disabled = !preset; + if (preset) { + signaturePresetName.value = preset.name; + applySignaturePreview(preset.dataUrl); + setSignatureStatus("Uložený podpis načten."); + } +}); +saveSignaturePresetButton.addEventListener("click", async () => { + if (!signatureDataUrl) { + setSignatureStatus("Nejprve nahrajte nebo vyberte podpis.", true); + return; + } + try { + const response = await apiRequest("/api/signature-presets", { + method: "POST", + body: JSON.stringify({ + name: signaturePresetName.value, + dataUrl: signatureDataUrl + }) + }); + const preset = response.preset; + signaturePresets = [ + ...signaturePresets.filter((entry) => entry.id !== preset.id && entry.name !== preset.name), + preset + ].sort((left, right) => left.name.localeCompare(right.name)); + renderSignaturePresetOptions(String(preset.id)); + signaturePresetName.value = preset.name; + setSignatureStatus("Podpis byl uložen."); + } catch (error) { + setSignatureStatus(error.message, true); + } +}); +deleteSignaturePresetButton.addEventListener("click", async () => { + if (!signaturePresetSelect.value) { + return; + } + try { + await apiRequest(`/api/signature-presets/${signaturePresetSelect.value}`, { method: "DELETE" }); + signaturePresets = signaturePresets.filter(({ id }) => String(id) !== signaturePresetSelect.value); + signaturePresetName.value = ""; + renderSignaturePresetOptions(); + } catch (error) { + setSignatureStatus(error.message, true); + } +}); removeSignatureButton.addEventListener("click", (event) => { event.stopPropagation(); applySignaturePreview(null); @@ -374,6 +444,9 @@ const clearInvoiceEditor = () => { paymentPresetSelect.value = ""; paymentPresetName.value = ""; deletePaymentPresetButton.disabled = true; + signaturePresetSelect.value = ""; + signaturePresetName.value = ""; + deleteSignaturePresetButton.disabled = true; ["cisloUctu", "iban", "swift"].forEach((id) => { document.getElementById(id).value = ""; }); @@ -403,6 +476,9 @@ const loadInvoiceIntoEditor = (invoice) => { document.getElementById(id).value = invoice.data.payment?.[id] || ""; }); applySignaturePreview(invoice.data.signature || null); + signaturePresetSelect.value = ""; + signaturePresetName.value = ""; + deleteSignaturePresetButton.disabled = true; setSignatureStatus(""); itemsContainer.replaceChildren(); itemIndex = 0; @@ -629,7 +705,7 @@ deleteClientButton.addEventListener("click", async () => { const showInvoiceApp = async () => { authScreen.hidden = true; document.getElementById("main-container").hidden = false; - await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadNumberingSettings()]); + await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadSignaturePresets(), loadNumberingSettings()]); showInvoiceScreen("list"); }; diff --git a/app/src/static/style.css b/app/src/static/style.css index 9344a13..256aa51 100644 --- a/app/src/static/style.css +++ b/app/src/static/style.css @@ -682,6 +682,56 @@ body { margin-top: 0.5rem; } +.signature-save-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.signature-save-row input { + min-width: 0; + padding: 0.55rem 0.7rem; + color: var(--ink); + background: #fff; + border: 1px solid #c8d0d5; + border-radius: 0.25rem; + font: inherit; +} + +.signature-save-row button { + padding: 0.55rem 0.7rem; + color: var(--navy); + background: transparent; + border: 1px solid var(--blue); + border-radius: 0.25rem; + cursor: pointer; + font: inherit; + font-size: 0.85rem; + font-weight: 700; + white-space: nowrap; +} + +.signature-save-row button:hover:not(:disabled) { + color: #fff; + background: var(--blue); +} + +.signature-save-row .secondary-button { + color: #8c3c36; + border-color: #c47a72; +} + +.signature-save-row .secondary-button:hover:not(:disabled) { + color: #fff; + background: #a53c36; +} + +.signature-save-row button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .signature-status { margin: 0.4rem 0 0; min-height: 1.1rem; @@ -963,6 +1013,10 @@ select:focus { grid-template-columns: 1fr; } + .signature-save-row { + grid-template-columns: 1fr; + } + .delete-item-button { justify-self: end; } diff --git a/app/test/app.test.js b/app/test/app.test.js index 38313b7..38fc7e8 100644 --- a/app/test/app.test.js +++ b/app/test/app.test.js @@ -1,4 +1,4 @@ -const { normalizeInvoiceData } = require('../src/invoice-data'); +const { isValidSignatureDataUrl, normalizeInvoiceData } = require('../src/invoice-data'); describe('normalizeInvoiceData', () => { test('calculates the total from validated line items', () => { @@ -49,4 +49,10 @@ describe('normalizeInvoiceData', () => { }); expect(withoutSignature.signature).toBeNull(); }); + + test('validates only supported base64 signature image data URLs', () => { + expect(isValidSignatureDataUrl('data:image/jpeg;base64,aGVsbG8=')).toBe(true); + expect(isValidSignatureDataUrl('data:image/gif;base64,aGVsbG8=')).toBe(false); + expect(isValidSignatureDataUrl('data:image/png;base64,not valid')).toBe(false); + }); });