Author SHA1 Message Date
odwetaandCopilot App bcb6d9df99 Store a single signature per user, like supplier profile
Replace the signature preset dropdown (multiple named signatures) with
a single saved signature per user, mirroring the supplier profile
behavior: Save/Load buttons, auto-loaded on the new invoice screen.

- New signature_profiles table (one row per user), migrated from any
  existing signature_presets data.
- New /api/signature GET/PUT/DELETE endpoints replacing
  /api/signature-presets.
- Updated frontend to save/load the single signature instead of a
  named preset dropdown.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:06:38 +02:00
odweta cd553bbaea update: auth intro on frontpage 2026-09-10 17:53:19 +02:00
odweta 2f9a7c587c Merge pull request #9 from odweta/odweta-default-invoice-date
Default issue date field to today's date
2026-09-10 17:44:15 +02:00
odweta cfcab16149 Merge pull request #10 from odweta/odweta-saved-signatures
Add saved signature presets
2026-09-10 17:43:59 +02:00
odwetaandCopilot App 1317c7297f Add saved signature presets
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:43:23 +02:00
odwetaandCopilot App 3081b2a99b Default issue date field to today's date
Prefill 'Datum vystaveni' with the current local date when opening a
new invoice, instead of leaving it blank. Also switch date formatting
to use local date components instead of toISOString() to avoid UTC
timezone shifting the displayed date.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:42:31 +02:00
odweta 3e5a0813b0 Merge pull request #8 from odweta/odweta-stunning-engine
Make invoice due date optional with 14-day default
2026-09-10 17:31:02 +02:00
odweta 65c11ba86e Merge origin/main into odweta-stunning-engine 2026-09-10 17:29:53 +02:00
odwetaandCopilot App d440788d0d Make invoice due date optional with 14-day default
Datum vystaveni defaults to today and Datum splatnosti defaults to
14 days after the issue date. Both fields are now optional date
inputs and only override the default when a user enters a custom
value. Applies to the invoice editor and generated PDF.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:29:19 +02:00
odweta 1c5c074fa1 Merge pull request #7 from odweta/odweta-urban-goggles
Add optional signature upload to invoice PDF
2026-09-10 17:27:55 +02:00
odweta d2d3292a73 Merge pull request #5 from odweta/odweta-decrease-pdf-line-height
Decrease PDF invoice item row height by ~10%
2026-09-10 17:27:36 +02:00
odwetaandCopilot App a14500c7ab Decrease PDF invoice item row height by ~10%
Reduce the item table row height from 25pt to 22.5pt and re-center
the cell text within the shorter rows. The header row height is
unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:22:31 +02:00
6 changed files with 183 additions and 37 deletions
+74 -10
View File
@@ -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,43 @@ app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
}
});
app.get('/api/signature', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.json({ signature: result.rowCount ? result.rows[0].data_url : null });
} catch (error) {
next(error);
}
});
app.put('/api/signature', requireAuth, async (req, res, next) => {
try {
const dataUrl = req.body.dataUrl;
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_profiles (user_id, data_url)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET data_url = EXCLUDED.data_url
RETURNING *
`, [req.userId, dataUrl]);
res.json({ signature: result.rows[0].data_url });
} catch (error) {
next(error);
}
});
app.delete('/api/signature', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const invoiceResponse = (row) => ({
id: row.id,
invoiceNumber: row.invoice_number,
@@ -480,6 +517,20 @@ app.delete('/api/invoices', requireAuth, async (req, res, next) => {
});
const pdfText = (value) => String(value || 'Neuvedeno');
const formatCzechDate = (value) => {
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('cs-CZ');
};
const toDateInputValue = (date) => date.toISOString().slice(0, 10);
const resolveIssueDate = (issueDate) => issueDate || toDateInputValue(new Date());
const resolveDueDate = (dueDate, issueDate) => {
if (dueDate) {
return dueDate;
}
const due = new Date(`${resolveIssueDate(issueDate)}T00:00:00`);
due.setDate(due.getDate() + 14);
return toDateInputValue(due);
};
const formatCzechNumber = (value) => Number(value || 0).toLocaleString('cs-CZ', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
@@ -554,13 +605,25 @@ const renderInvoicePdf = async (document, invoice) => {
const partyWidth = (pageWidth - 24) / 2;
const customerX = document.page.margins.left + partyWidth + 24;
const issueDate = resolveIssueDate(data.issueDate);
const dueDate = resolveDueDate(data.dueDate, data.issueDate);
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, {
width: partyWidth,
align: 'right'
});
document.y = headerY + 40;
document.fillColor(bodyText).font(regularFont).fontSize(9)
.text(`Datum vystavení: ${formatCzechDate(issueDate)}`, customerX, headerY + 22, {
width: partyWidth,
align: 'right'
})
.text(`Datum splatnosti: ${formatCzechDate(dueDate)}`, customerX, headerY + 35, {
width: partyWidth,
align: 'right'
});
document.y = headerY + 52;
const partyTop = document.y;
const drawParty = (x, title, profile, name) => {
@@ -575,28 +638,29 @@ const renderInvoicePdf = async (document, invoice) => {
const tableTop = document.y + 12;
const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth];
const rowHeight = 25;
document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).fill('#eef1f3');
const headerRowHeight = 25;
const itemRowHeight = 22.5;
document.rect(document.page.margins.left, tableTop, pageWidth, headerRowHeight).fill('#eef1f3');
document.fillColor(navy).font(boldFont).fontSize(9);
['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => {
document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, {
width: columns[index + 1] - columns[index] - 12
});
});
document.y = tableTop + rowHeight;
document.y = tableTop + headerRowHeight;
(data.items || []).forEach((item, index) => {
const rowTop = document.y;
document.rect(document.page.margins.left, rowTop, pageWidth, rowHeight)
document.rect(document.page.margins.left, rowTop, pageWidth, itemRowHeight)
.fill(index % 2 ? lightBlue : warm);
document.moveTo(document.page.margins.left, rowTop + rowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight)
document.moveTo(document.page.margins.left, rowTop + itemRowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + itemRowHeight)
.strokeColor(line).lineWidth(0.7).stroke();
document.fillColor(bodyText).font(regularFont).fontSize(9);
[pdfText(item.popis), formatCzechNumber(item.mnozstvi), pdfText(item.mernaJednotka), `${formatCzechNumber(item.cenaZaMj)}`]
.forEach((value, columnIndex) => document.text(value, document.page.margins.left + columns[columnIndex] + 6, rowTop + 8, {
.forEach((value, columnIndex) => document.text(value, document.page.margins.left + columns[columnIndex] + 6, rowTop + 6, {
width: columns[columnIndex + 1] - columns[columnIndex] - 12
}));
document.y = rowTop + rowHeight;
document.y = rowTop + itemRowHeight;
});
document.y += 18;
+20
View File
@@ -81,10 +81,30 @@ 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)
);
CREATE TABLE IF NOT EXISTS signature_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data_url TEXT NOT NULL
);
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 '';
ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba';
INSERT INTO signature_profiles (user_id, data_url)
SELECT DISTINCT ON (user_id) user_id, data_url
FROM signature_presets
ORDER BY user_id, created_at DESC
ON CONFLICT (user_id) DO NOTHING;
`).catch((error) => {
schemaPromise = undefined;
throw error;
+5 -2
View File
@@ -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 };
+14 -10
View File
@@ -10,8 +10,8 @@
<section id="auth-screen" aria-labelledby="auth-title">
<div class="auth-card">
<p class="eyebrow">FAKTUROVAČ</p>
<h1 id="auth-title">Vaše faktury, bezpečně po ruce</h1>
<p class="auth-intro">Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.</p>
<h1 id="auth-title">Vaše faktury, bezpečně po ruce.</h1>
<p class="auth-intro">Spravujte faktury přehledně.</p>
<form id="auth-form">
<div class="form-group">
<label for="authEmail">E-mail</label>
@@ -325,13 +325,13 @@
<div class="due-date-group">
<div class="form-group">
<label for="dueDateOption">Datum splatnosti</label>
<select id="dueDateOption">
<option value="14">14 dní</option>
<option value="30">30 dní</option>
</select>
<label for="issueDate">Datum vystavení</label>
<input type="date" id="issueDate" name="issueDate" placeholder="Dnešní datum" />
</div>
<div class="form-group">
<label for="dueDate">Datum splatnosti</label>
<input type="date" id="dueDate" name="dueDate" placeholder="14 dní od vystavení" />
</div>
<output id="dueDateDisplay" class="due-date-display"></output>
</div>
</div>
@@ -339,14 +339,18 @@
<div class="form-group signature-group">
<label for="signatureInput">Podpis (nepovinné)</label>
<div id="signatureDropzone" class="signature-dropzone" tabindex="0" role="button" aria-label="Nahrát podpis kliknutím nebo přetažením souboru">
<img id="signaturePreview" class="signature-preview" alt="Náhled podpisu" hidden />
<img id="signaturePreview" class="signature-preview" alt="Náhled podpisu" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" hidden />
<p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p>
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div>
<div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</div>
<p id="signatureStatus" class="signature-status" role="status" aria-live="polite"></p>
<div class="saved-data-actions">
<button type="button" id="saveSignatureButton">Uložit podpis</button>
<button type="button" id="loadSignatureButton" class="secondary-button">Načíst uložený</button>
<span id="signatureStatus" class="signature-status" role="status" aria-live="polite"></span>
</div>
</div>
</div>
+63 -14
View File
@@ -46,8 +46,8 @@ const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumbe
const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
const dueDateOption = document.getElementById("dueDateOption");
const dueDateDisplay = document.getElementById("dueDateDisplay");
const issueDateInput = document.getElementById("issueDate");
const dueDateInput = document.getElementById("dueDate");
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName");
const savePaymentPresetButton = document.getElementById("savePaymentPresetButton");
@@ -57,6 +57,8 @@ const signaturePreview = document.getElementById("signaturePreview");
const signatureDropzoneText = document.getElementById("signatureDropzoneText");
const removeSignatureButton = document.getElementById("removeSignatureButton");
const signatureStatus = document.getElementById("signatureStatus");
const saveSignatureButton = document.getElementById("saveSignatureButton");
const loadSignatureButton = document.getElementById("loadSignatureButton");
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -99,12 +101,39 @@ const applySignaturePreview = (dataUrl) => {
removeSignatureButton.hidden = false;
} else {
signaturePreview.hidden = true;
signaturePreview.src = "";
signaturePreview.removeAttribute("src");
signatureDropzoneText.hidden = false;
removeSignatureButton.hidden = true;
}
};
const saveSignature = async () => {
if (!signatureDataUrl) {
setSignatureStatus("Nejprve nahrajte podpis.", true);
return;
}
try {
await apiRequest("/api/signature", {
method: "PUT",
body: JSON.stringify({ dataUrl: signatureDataUrl })
});
setSignatureStatus("Podpis byl uložen.");
} catch (error) {
setSignatureStatus(error.message, true);
}
};
const loadSignature = async () => {
const { signature } = await apiRequest("/api/signature");
if (signature) {
applySignaturePreview(signature);
setSignatureStatus("Uloženo na serveru");
return true;
}
setSignatureStatus("Zatím není uložený podpis");
return false;
};
const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
@@ -162,6 +191,15 @@ signatureInput.addEventListener("change", () => {
handleSignatureFile(signatureInput.files[0]);
signatureInput.value = "";
});
saveSignatureButton.addEventListener("click", saveSignature);
loadSignatureButton.addEventListener("click", async () => {
try {
await loadSignature();
setSignatureStatus("Podpis načten.");
} catch (error) {
setSignatureStatus(error.message, true);
}
});
removeSignatureButton.addEventListener("click", (event) => {
event.stopPropagation();
applySignaturePreview(null);
@@ -207,17 +245,21 @@ const getClientLabel = (client) => [
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
const getDueDays = () => Number.parseInt(dueDateOption.value, 10) || 14;
const toDateInputValue = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
const getIssueDate = () => issueDateInput.value || toDateInputValue(new Date());
const getDueDate = () => {
const dueDate = new Date();
dueDate.setHours(0, 0, 0, 0);
dueDate.setDate(dueDate.getDate() + getDueDays());
return dueDate.toISOString().slice(0, 10);
};
const updateDueDate = () => {
dueDateDisplay.textContent = `Splatnost: ${formatCzechDate(getDueDate())}`;
if (dueDateInput.value) {
return dueDateInput.value;
}
const dueDate = new Date(`${getIssueDate()}T00:00:00`);
dueDate.setDate(dueDate.getDate() + 14);
return toDateInputValue(dueDate);
};
const updateClientTypeFields = () => {
@@ -333,6 +375,8 @@ deletePaymentPresetButton.addEventListener("click", async () => {
});
const readInvoiceData = () => ({
issueDate: getIssueDate(),
dueDate: getDueDate(),
supplier: {
...readProfileFields("dodavatel"),
typSubjektu: supplierTypeSelect.value
@@ -363,6 +407,8 @@ const clearInvoiceEditor = () => {
currentInvoiceId = null;
invoiceNumberDisplay.textContent = "Nová faktura";
invoiceSaveStatus.textContent = "";
issueDateInput.value = toDateInputValue(new Date());
dueDateInput.value = "";
writeProfileFields("odberatel", {});
clientSelect.value = "";
clientTypeSelect.value = "osoba";
@@ -384,6 +430,8 @@ const loadInvoiceIntoEditor = (invoice) => {
currentInvoiceId = invoice.id;
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
invoiceSaveStatus.textContent = "";
issueDateInput.value = invoice.data.issueDate || "";
dueDateInput.value = invoice.data.dueDate || "";
writeProfileFields("dodavatel", invoice.data.supplier || {});
supplierTypeSelect.value = invoice.data.supplier?.typSubjektu || "osoba";
updateSupplierTypeFields();
@@ -435,8 +483,9 @@ const loadInvoices = async () => {
renderInvoiceList();
};
const openNewInvoice = () => {
const openNewInvoice = async () => {
clearInvoiceEditor();
await loadSignature();
showInvoiceScreen("editor");
};
@@ -623,7 +672,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(), loadSignature(), loadNumberingSettings()]);
showInvoiceScreen("list");
};
+7 -1
View File
@@ -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);
});
});