Author SHA1 Message Date
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
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
7 changed files with 276 additions and 33 deletions
+88 -10
View File
@@ -5,7 +5,7 @@ const archiver = require('archiver');
const PDFDocument = require('pdfkit'); const PDFDocument = require('pdfkit');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const { pool, initializeDatabase } = require('./db'); const { pool, initializeDatabase } = require('./db');
const { normalizeInvoiceData } = require('./invoice-data'); const { isValidSignatureDataUrl, normalizeInvoiceData } = require('./invoice-data');
const app = express(); const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; 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) => ({ const invoiceResponse = (row) => ({
id: row.id, id: row.id,
invoiceNumber: row.invoice_number, invoiceNumber: row.invoice_number,
@@ -480,6 +531,20 @@ app.delete('/api/invoices', requireAuth, async (req, res, next) => {
}); });
const pdfText = (value) => String(value || 'Neuvedeno'); 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', { const formatCzechNumber = (value) => Number(value || 0).toLocaleString('cs-CZ', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2 maximumFractionDigits: 2
@@ -554,13 +619,25 @@ const renderInvoicePdf = async (document, invoice) => {
const partyWidth = (pageWidth - 24) / 2; const partyWidth = (pageWidth - 24) / 2;
const customerX = document.page.margins.left + partyWidth + 24; const customerX = document.page.margins.left + partyWidth + 24;
const issueDate = resolveIssueDate(data.issueDate);
const dueDate = resolveDueDate(data.dueDate, data.issueDate);
const headerY = document.y; const headerY = document.y;
document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY); 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, { document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, {
width: partyWidth, width: partyWidth,
align: 'right' 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 partyTop = document.y;
const drawParty = (x, title, profile, name) => { const drawParty = (x, title, profile, name) => {
@@ -575,28 +652,29 @@ const renderInvoicePdf = async (document, invoice) => {
const tableTop = document.y + 12; const tableTop = document.y + 12;
const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth]; const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth];
const rowHeight = 25; const headerRowHeight = 25;
document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).fill('#eef1f3'); const itemRowHeight = 22.5;
document.rect(document.page.margins.left, tableTop, pageWidth, headerRowHeight).fill('#eef1f3');
document.fillColor(navy).font(boldFont).fontSize(9); document.fillColor(navy).font(boldFont).fontSize(9);
['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => { ['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => {
document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, { document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, {
width: columns[index + 1] - columns[index] - 12 width: columns[index + 1] - columns[index] - 12
}); });
}); });
document.y = tableTop + rowHeight; document.y = tableTop + headerRowHeight;
(data.items || []).forEach((item, index) => { (data.items || []).forEach((item, index) => {
const rowTop = document.y; 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); .fill(index % 2 ? lightBlue : warm);
document.moveTo(document.page.margins.left, rowTop + rowHeight) document.moveTo(document.page.margins.left, rowTop + itemRowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight) .lineTo(document.page.margins.left + pageWidth, rowTop + itemRowHeight)
.strokeColor(line).lineWidth(0.7).stroke(); .strokeColor(line).lineWidth(0.7).stroke();
document.fillColor(bodyText).font(regularFont).fontSize(9); document.fillColor(bodyText).font(regularFont).fontSize(9);
[pdfText(item.popis), formatCzechNumber(item.mnozstvi), pdfText(item.mernaJednotka), `${formatCzechNumber(item.cenaZaMj)}`] [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 width: columns[columnIndex + 1] - columns[columnIndex] - 12
})); }));
document.y = rowTop + rowHeight; document.y = rowTop + itemRowHeight;
}); });
document.y += 18; document.y += 18;
+9
View File
@@ -81,6 +81,15 @@ const initializeDatabase = () => {
UNIQUE (user_id, name) 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 nazev_spolecnosti TEXT NOT NULL DEFAULT '';
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; 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 nazev_spolecnosti TEXT NOT NULL DEFAULT '';
+5 -2
View File
@@ -1,5 +1,8 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/; 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) => { const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) { if (!data || !Array.isArray(data.items)) {
return null; return null;
@@ -18,7 +21,7 @@ const normalizeInvoiceData = (data) => {
return null; return null;
} }
const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature) const signature = isValidSignatureDataUrl(data.signature)
? data.signature ? data.signature
: null; : null;
@@ -32,4 +35,4 @@ const normalizeInvoiceData = (data) => {
}; };
}; };
module.exports = { normalizeInvoiceData }; module.exports = { isValidSignatureDataUrl, normalizeInvoiceData };
+18 -7
View File
@@ -325,24 +325,35 @@
<div class="due-date-group"> <div class="due-date-group">
<div class="form-group"> <div class="form-group">
<label for="dueDateOption">Datum splatnosti</label> <label for="issueDate">Datum vystavení</label>
<select id="dueDateOption"> <input type="date" id="issueDate" name="issueDate" placeholder="Dnešní datum" />
<option value="14">14 dní</option> </div>
<option value="30">30 dní</option> <div class="form-group">
</select> <label for="dueDate">Datum splatnosti</label>
<input type="date" id="dueDate" name="dueDate" placeholder="14 dní od vystavení" />
</div> </div>
<output id="dueDateDisplay" class="due-date-display"></output>
</div> </div>
</div> </div>
<div class="form-chapter signature-row"> <div class="form-chapter signature-row">
<div class="form-group signature-group"> <div class="form-group signature-group">
<label for="signatureInput">Podpis (nepovinné)</label> <label for="signatureInput">Podpis (nepovinné)</label>
<div class="form-group">
<label for="signaturePresetSelect">Uložený podpis</label>
<select id="signaturePresetSelect">
<option value="">Vyberte podpis</option>
</select>
</div>
<div id="signatureDropzone" class="signature-dropzone" tabindex="0" role="button" aria-label="Nahrát podpis kliknutím nebo přetažením souboru"> <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> <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 /> <input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div> </div>
<div class="signature-save-row">
<input id="signaturePresetName" type="text" placeholder="Název podpisu" aria-label="Název podpisu" />
<button type="button" id="saveSignaturePresetButton">Uložit podpis</button>
<button type="button" id="deleteSignaturePresetButton" class="secondary-button" disabled>Smazat</button>
</div>
<div class="signature-actions"> <div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button> <button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</div> </div>
+95 -13
View File
@@ -46,8 +46,8 @@ const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumbe
const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton"); const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton"); const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus"); const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
const dueDateOption = document.getElementById("dueDateOption"); const issueDateInput = document.getElementById("issueDate");
const dueDateDisplay = document.getElementById("dueDateDisplay"); const dueDateInput = document.getElementById("dueDate");
const paymentPresetSelect = document.getElementById("paymentPresetSelect"); const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName"); const paymentPresetName = document.getElementById("paymentPresetName");
const savePaymentPresetButton = document.getElementById("savePaymentPresetButton"); const savePaymentPresetButton = document.getElementById("savePaymentPresetButton");
@@ -57,6 +57,10 @@ const signaturePreview = document.getElementById("signaturePreview");
const signatureDropzoneText = document.getElementById("signatureDropzoneText"); const signatureDropzoneText = document.getElementById("signatureDropzoneText");
const removeSignatureButton = document.getElementById("removeSignatureButton"); const removeSignatureButton = document.getElementById("removeSignatureButton");
const signatureStatus = document.getElementById("signatureStatus"); 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 loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -78,6 +82,7 @@ let itemIndex = 0;
let clients = []; let clients = [];
let invoices = []; let invoices = [];
let paymentPresets = []; let paymentPresets = [];
let signaturePresets = [];
let currentInvoiceId = null; let currentInvoiceId = null;
let isRegistrationMode = false; let isRegistrationMode = false;
let signatureDataUrl = null; let signatureDataUrl = null;
@@ -99,12 +104,30 @@ const applySignaturePreview = (dataUrl) => {
removeSignatureButton.hidden = false; removeSignatureButton.hidden = false;
} else { } else {
signaturePreview.hidden = true; signaturePreview.hidden = true;
signaturePreview.src = ""; signaturePreview.removeAttribute("src");
signatureDropzoneText.hidden = false; signatureDropzoneText.hidden = false;
removeSignatureButton.hidden = true; removeSignatureButton.hidden = true;
} }
}; };
const renderSignaturePresetOptions = (selectedId = signaturePresetSelect.value) => {
signaturePresetSelect.innerHTML = "<option value=\"\">Vyberte podpis</option>";
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 readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image(); const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
@@ -162,6 +185,53 @@ signatureInput.addEventListener("change", () => {
handleSignatureFile(signatureInput.files[0]); handleSignatureFile(signatureInput.files[0]);
signatureInput.value = ""; 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) => { removeSignatureButton.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
applySignaturePreview(null); applySignaturePreview(null);
@@ -207,17 +277,17 @@ const getClientLabel = (client) => [
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ"); const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
const getDueDays = () => Number.parseInt(dueDateOption.value, 10) || 14; const toDateInputValue = (date) => date.toISOString().slice(0, 10);
const getIssueDate = () => issueDateInput.value || toDateInputValue(new Date());
const getDueDate = () => { const getDueDate = () => {
const dueDate = new Date(); if (dueDateInput.value) {
dueDate.setHours(0, 0, 0, 0); return dueDateInput.value;
dueDate.setDate(dueDate.getDate() + getDueDays()); }
return dueDate.toISOString().slice(0, 10); const dueDate = new Date(`${getIssueDate()}T00:00:00`);
}; dueDate.setDate(dueDate.getDate() + 14);
return toDateInputValue(dueDate);
const updateDueDate = () => {
dueDateDisplay.textContent = `Splatnost: ${formatCzechDate(getDueDate())}`;
}; };
const updateClientTypeFields = () => { const updateClientTypeFields = () => {
@@ -333,6 +403,8 @@ deletePaymentPresetButton.addEventListener("click", async () => {
}); });
const readInvoiceData = () => ({ const readInvoiceData = () => ({
issueDate: getIssueDate(),
dueDate: getDueDate(),
supplier: { supplier: {
...readProfileFields("dodavatel"), ...readProfileFields("dodavatel"),
typSubjektu: supplierTypeSelect.value typSubjektu: supplierTypeSelect.value
@@ -363,6 +435,8 @@ const clearInvoiceEditor = () => {
currentInvoiceId = null; currentInvoiceId = null;
invoiceNumberDisplay.textContent = "Nová faktura"; invoiceNumberDisplay.textContent = "Nová faktura";
invoiceSaveStatus.textContent = ""; invoiceSaveStatus.textContent = "";
issueDateInput.value = "";
dueDateInput.value = "";
writeProfileFields("odberatel", {}); writeProfileFields("odberatel", {});
clientSelect.value = ""; clientSelect.value = "";
clientTypeSelect.value = "osoba"; clientTypeSelect.value = "osoba";
@@ -370,6 +444,9 @@ const clearInvoiceEditor = () => {
paymentPresetSelect.value = ""; paymentPresetSelect.value = "";
paymentPresetName.value = ""; paymentPresetName.value = "";
deletePaymentPresetButton.disabled = true; deletePaymentPresetButton.disabled = true;
signaturePresetSelect.value = "";
signaturePresetName.value = "";
deleteSignaturePresetButton.disabled = true;
["cisloUctu", "iban", "swift"].forEach((id) => { ["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = ""; document.getElementById(id).value = "";
}); });
@@ -384,6 +461,8 @@ const loadInvoiceIntoEditor = (invoice) => {
currentInvoiceId = invoice.id; currentInvoiceId = invoice.id;
invoiceNumberDisplay.textContent = invoice.invoiceNumber; invoiceNumberDisplay.textContent = invoice.invoiceNumber;
invoiceSaveStatus.textContent = ""; invoiceSaveStatus.textContent = "";
issueDateInput.value = invoice.data.issueDate || "";
dueDateInput.value = invoice.data.dueDate || "";
writeProfileFields("dodavatel", invoice.data.supplier || {}); writeProfileFields("dodavatel", invoice.data.supplier || {});
supplierTypeSelect.value = invoice.data.supplier?.typSubjektu || "osoba"; supplierTypeSelect.value = invoice.data.supplier?.typSubjektu || "osoba";
updateSupplierTypeFields(); updateSupplierTypeFields();
@@ -397,6 +476,9 @@ const loadInvoiceIntoEditor = (invoice) => {
document.getElementById(id).value = invoice.data.payment?.[id] || ""; document.getElementById(id).value = invoice.data.payment?.[id] || "";
}); });
applySignaturePreview(invoice.data.signature || null); applySignaturePreview(invoice.data.signature || null);
signaturePresetSelect.value = "";
signaturePresetName.value = "";
deleteSignaturePresetButton.disabled = true;
setSignatureStatus(""); setSignatureStatus("");
itemsContainer.replaceChildren(); itemsContainer.replaceChildren();
itemIndex = 0; itemIndex = 0;
@@ -623,7 +705,7 @@ deleteClientButton.addEventListener("click", async () => {
const showInvoiceApp = async () => { const showInvoiceApp = async () => {
authScreen.hidden = true; authScreen.hidden = true;
document.getElementById("main-container").hidden = false; 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"); showInvoiceScreen("list");
}; };
+54
View File
@@ -682,6 +682,56 @@ body {
margin-top: 0.5rem; 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 { .signature-status {
margin: 0.4rem 0 0; margin: 0.4rem 0 0;
min-height: 1.1rem; min-height: 1.1rem;
@@ -963,6 +1013,10 @@ select:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.signature-save-row {
grid-template-columns: 1fr;
}
.delete-item-button { .delete-item-button {
justify-self: end; justify-self: end;
} }
+7 -1
View File
@@ -1,4 +1,4 @@
const { normalizeInvoiceData } = require('../src/invoice-data'); const { isValidSignatureDataUrl, normalizeInvoiceData } = require('../src/invoice-data');
describe('normalizeInvoiceData', () => { describe('normalizeInvoiceData', () => {
test('calculates the total from validated line items', () => { test('calculates the total from validated line items', () => {
@@ -49,4 +49,10 @@ describe('normalizeInvoiceData', () => {
}); });
expect(withoutSignature.signature).toBeNull(); 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);
});
}); });