Make invoice due date optional with 14-day default #8

Merged
odweta merged 2 commits from odweta-stunning-engine into main 2026-09-10 17:31:02 +02:00
7 changed files with 344 additions and 18 deletions
Showing only changes of commit 65c11ba86e - Show all commits
+69 -17
View File
@@ -10,7 +10,7 @@ const { normalizeInvoiceData } = require('./invoice-data');
const app = express(); const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; 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) => { const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (error, derivedKey) => { crypto.scrypt(password, salt, 64, (error, derivedKey) => {
@@ -227,6 +227,33 @@ app.put('/api/supplier', requireAuth, async (req, res, next) => {
} }
}); });
app.get('/api/settings', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT invoice_starting_number FROM user_settings WHERE user_id = $1', [req.userId]);
res.json({ settings: { invoiceStartingNumber: result.rowCount ? result.rows[0].invoice_starting_number : 1 } });
} catch (error) {
next(error);
}
});
app.put('/api/settings', requireAuth, async (req, res, next) => {
try {
const invoiceStartingNumber = Number.parseInt(req.body.invoiceStartingNumber, 10);
if (!Number.isInteger(invoiceStartingNumber) || invoiceStartingNumber < 1) {
res.status(400).json({ error: 'Počáteční číslo faktury musí být kladné celé číslo.' });
return;
}
const result = await pool.query(`
INSERT INTO user_settings (user_id, invoice_starting_number) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET invoice_starting_number = EXCLUDED.invoice_starting_number
RETURNING invoice_starting_number
`, [req.userId, invoiceStartingNumber]);
res.json({ settings: { invoiceStartingNumber: result.rows[0].invoice_starting_number } });
} catch (error) {
next(error);
}
});
app.get('/api/clients', requireAuth, async (req, res, next) => { app.get('/api/clients', requireAuth, async (req, res, next) => {
try { try {
const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]); const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]);
@@ -376,13 +403,21 @@ app.post('/api/invoices', requireAuth, async (req, res, next) => {
const invoiceYear = new Date().getFullYear(); const invoiceYear = new Date().getFullYear();
await client.query('BEGIN'); await client.query('BEGIN');
const settingsResult = await client.query(
'SELECT invoice_starting_number FROM user_settings WHERE user_id = $1',
[req.userId]
);
const invoiceStartingNumber = settingsResult.rowCount ? settingsResult.rows[0].invoice_starting_number : 1;
await client.query(` await client.query(`
INSERT INTO invoice_counters (user_id, invoice_year, last_number) INSERT INTO invoice_counters (user_id, invoice_year, last_number)
SELECT $1, $2, COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0) SELECT $1, $2, GREATEST(
COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0),
$4 - 1
)
FROM invoices FROM invoices
WHERE user_id = $1 AND invoice_number LIKE $3 WHERE user_id = $1 AND invoice_number LIKE $3
ON CONFLICT (user_id, invoice_year) DO NOTHING ON CONFLICT (user_id, invoice_year) DO NOTHING
`, [req.userId, invoiceYear, `F-${invoiceYear}-%`]); `, [req.userId, invoiceYear, `F-${invoiceYear}-%`, invoiceStartingNumber]);
const counterResult = await client.query(` const counterResult = await client.query(`
UPDATE invoice_counters UPDATE invoice_counters
SET last_number = last_number + 1 SET last_number = last_number + 1
@@ -514,6 +549,7 @@ const renderInvoicePdf = async (document, invoice) => {
width: 180, width: 180,
margin: 1 margin: 1
}) : null; }) : 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 pageWidth = document.page.width - document.page.margins.left - document.page.margins.right;
const navy = '#183b56'; const navy = '#183b56';
const line = '#d8d4cc'; const line = '#d8d4cc';
@@ -565,28 +601,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;
@@ -598,16 +635,31 @@ const renderInvoicePdf = async (document, invoice) => {
document.y += 35; document.y += 35;
document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y) document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y)
.strokeColor(navy).lineWidth(1.5).stroke(); .strokeColor(navy).lineWidth(1.5).stroke();
document.y += 12; const paymentCardHeight = 105;
const paymentTop = document.y; const paymentTop = document.page.height - document.page.margins.bottom - paymentCardHeight;
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line); const paymentLineHeight = 18;
const qrSize = paymentLineHeight * 3;
if (signatureBuffer) {
const signatureWidth = 145;
const signatureHeight = signatureWidth / 2;
const signatureGap = 8;
const signatureX = document.page.margins.left + pageWidth - signatureWidth;
document.image(signatureBuffer, signatureX, paymentTop - signatureGap - signatureHeight, {
fit: [signatureWidth, signatureHeight]
});
}
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, paymentCardHeight, 4)
.fillAndStroke('#f4f7fb', line);
document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10); document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10);
document.fillColor(bodyText).font(regularFont).fontSize(10) document.fillColor(bodyText).font(regularFont).fontSize(10)
.text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35) .text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35)
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55) .text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75); .text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight * 2);
if (qrBuffer) { if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 }); document.image(qrBuffer, document.page.margins.left + pageWidth - qrSize - 10, paymentTop + 30, {
width: qrSize,
height: qrSize
});
} }
document.end(); document.end();
}; };
+5
View File
@@ -65,6 +65,11 @@ const initializeDatabase = () => {
PRIMARY KEY (user_id, invoice_year) PRIMARY KEY (user_id, invoice_year)
); );
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
invoice_starting_number INTEGER NOT NULL DEFAULT 1 CHECK (invoice_starting_number > 0)
);
CREATE TABLE IF NOT EXISTS payment_presets ( CREATE TABLE IF NOT EXISTS payment_presets (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+7
View File
@@ -1,3 +1,5 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
const normalizeInvoiceData = (data) => { const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) { if (!data || !Array.isArray(data.items)) {
return null; return null;
@@ -16,11 +18,16 @@ const normalizeInvoiceData = (data) => {
return null; return null;
} }
const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature)
? data.signature
: null;
return { return {
supplier: data.supplier || {}, supplier: data.supplier || {},
customer: data.customer || {}, customer: data.customer || {},
payment: data.payment || {}, payment: data.payment || {},
items, items,
signature,
total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0) total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0)
}; };
}; };
+32
View File
@@ -65,11 +65,28 @@
<summary><span class="account-mark" aria-hidden="true"></span><span id="navEmail" class="signed-in-email"></span></summary> <summary><span class="account-mark" aria-hidden="true"></span><span id="navEmail" class="signed-in-email"></span></summary>
<div class="nav-menu-content account-menu-content"> <div class="nav-menu-content account-menu-content">
<button type="button" id="passwordButton" class="text-button">Změnit heslo</button> <button type="button" id="passwordButton" class="text-button">Změnit heslo</button>
<button type="button" id="numberingSettingsButton" class="text-button">Číslování faktur</button>
<button type="button" id="logoutButton" class="text-button">Odhlásit se</button> <button type="button" id="logoutButton" class="text-button">Odhlásit se</button>
</div> </div>
</details> </details>
</nav> </nav>
<section id="numberingSettingsPanel" class="password-panel" hidden>
<div id="numberingSettingsForm">
<h3>Číslování faktur</h3>
<div class="form-group">
<label for="invoiceStartingNumber">Počáteční číslo faktury (pro aktuální rok)</label>
<input id="invoiceStartingNumber" type="number" min="1" step="1" placeholder="1" />
<p class="field-hint">Určuje, od jakého čísla se budou generovat nové faktury v roce, kdy zatím žádnou nemáte. Výchozí hodnota je 1.</p>
</div>
<div class="saved-data-actions">
<button type="button" id="saveNumberingSettingsButton">Uložit</button>
<button type="button" id="cancelNumberingSettingsButton" class="secondary-button">Zrušit</button>
<span id="numberingSettingsStatus" role="status" aria-live="polite"></span>
</div>
</div>
</section>
<section id="passwordPanel" class="password-panel" hidden> <section id="passwordPanel" class="password-panel" hidden>
<div id="passwordForm"> <div id="passwordForm">
<h3>Změnit heslo</h3> <h3>Změnit heslo</h3>
@@ -318,6 +335,21 @@
</div> </div>
</div> </div>
<div class="form-chapter signature-row">
<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 />
<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>
</div>
<button type="button" id="printInvoiceButton"> <button type="button" id="printInvoiceButton">
Vygenerovat a vytisknout fakturu Vygenerovat a vytisknout fakturu
</button> </button>
+140 -1
View File
@@ -40,11 +40,23 @@ const passwordForm = document.getElementById("passwordForm");
const savePasswordButton = document.getElementById("savePasswordButton"); const savePasswordButton = document.getElementById("savePasswordButton");
const cancelPasswordButton = document.getElementById("cancelPasswordButton"); const cancelPasswordButton = document.getElementById("cancelPasswordButton");
const passwordStatus = document.getElementById("passwordStatus"); const passwordStatus = document.getElementById("passwordStatus");
const numberingSettingsButton = document.getElementById("numberingSettingsButton");
const numberingSettingsPanel = document.getElementById("numberingSettingsPanel");
const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumber");
const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
const issueDateInput = document.getElementById("issueDate"); const issueDateInput = document.getElementById("issueDate");
const dueDateInput = document.getElementById("dueDate"); 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");
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 loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -68,6 +80,93 @@ let invoices = [];
let paymentPresets = []; let paymentPresets = [];
let currentInvoiceId = null; let currentInvoiceId = null;
let isRegistrationMode = false; 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 = { const profileFieldMap = {
nazevSpolecnosti: "NazevSpolecnosti", nazevSpolecnosti: "NazevSpolecnosti",
@@ -249,6 +348,7 @@ const readInvoiceData = () => ({
iban: getValue("iban"), iban: getValue("iban"),
swift: getValue("swift") swift: getValue("swift")
}, },
signature: signatureDataUrl,
total: Number.parseFloat(getValue("total").replace(",", ".")) || 0, total: Number.parseFloat(getValue("total").replace(",", ".")) || 0,
items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => { items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => {
const inputs = item.querySelectorAll("input"); const inputs = item.querySelectorAll("input");
@@ -277,6 +377,8 @@ const clearInvoiceEditor = () => {
["cisloUctu", "iban", "swift"].forEach((id) => { ["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = ""; document.getElementById(id).value = "";
}); });
applySignaturePreview(null);
setSignatureStatus("");
itemsContainer.replaceChildren(); itemsContainer.replaceChildren();
itemIndex = 0; itemIndex = 0;
updateTotal(); updateTotal();
@@ -300,6 +402,8 @@ const loadInvoiceIntoEditor = (invoice) => {
["cisloUctu", "iban", "swift"].forEach((id) => { ["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = invoice.data.payment?.[id] || ""; document.getElementById(id).value = invoice.data.payment?.[id] || "";
}); });
applySignaturePreview(invoice.data.signature || null);
setSignatureStatus("");
itemsContainer.replaceChildren(); itemsContainer.replaceChildren();
itemIndex = 0; itemIndex = 0;
(invoice.data.items || []).forEach((item) => addInvoiceItem(item)); (invoice.data.items || []).forEach((item) => addInvoiceItem(item));
@@ -525,10 +629,27 @@ 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()]); await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadNumberingSettings()]);
showInvoiceScreen("list"); showInvoiceScreen("list");
}; };
const loadNumberingSettings = async () => {
try {
const { settings } = await apiRequest("/api/settings");
invoiceStartingNumberInput.value = settings.invoiceStartingNumber;
invoiceStartingNumberInput.placeholder = String(settings.invoiceStartingNumber);
} catch {
// Keep the placeholder default if settings cannot be loaded.
}
};
const showNumberingSettingsPanel = (visible) => {
numberingSettingsPanel.hidden = !visible;
if (!visible) {
numberingSettingsStatus.textContent = "";
}
};
const showPasswordPanel = (visible) => { const showPasswordPanel = (visible) => {
passwordPanel.hidden = !visible; passwordPanel.hidden = !visible;
if (!visible) { if (!visible) {
@@ -581,6 +702,24 @@ logoutButton.addEventListener("click", async () => {
passwordButton.addEventListener("click", () => showPasswordPanel(passwordPanel.hidden)); passwordButton.addEventListener("click", () => showPasswordPanel(passwordPanel.hidden));
cancelPasswordButton.addEventListener("click", () => showPasswordPanel(false)); cancelPasswordButton.addEventListener("click", () => showPasswordPanel(false));
numberingSettingsButton.addEventListener("click", () => showNumberingSettingsPanel(numberingSettingsPanel.hidden));
cancelNumberingSettingsButton.addEventListener("click", () => showNumberingSettingsPanel(false));
saveNumberingSettingsButton.addEventListener("click", async () => {
numberingSettingsStatus.textContent = "";
const invoiceStartingNumber = Number.parseInt(invoiceStartingNumberInput.value, 10) || 1;
try {
const { settings } = await apiRequest("/api/settings", {
method: "PUT",
body: JSON.stringify({ invoiceStartingNumber })
});
invoiceStartingNumberInput.value = settings.invoiceStartingNumber;
numberingSettingsStatus.textContent = "Uloženo";
} catch (error) {
numberingSettingsStatus.textContent = error.message;
}
});
savePasswordButton.addEventListener("click", async () => { savePasswordButton.addEventListener("click", async () => {
passwordStatus.textContent = ""; passwordStatus.textContent = "";
try { try {
+72
View File
@@ -595,6 +595,12 @@ body {
outline-offset: 1px; outline-offset: 1px;
} }
.field-hint {
margin: 0.35rem 0 0;
color: var(--muted-ink);
font-size: 0.8rem;
}
.form-group output { .form-group output {
min-height: 1.5rem; min-height: 1.5rem;
padding: 0.25rem 0; padding: 0.25rem 0;
@@ -621,6 +627,72 @@ body {
gap: 2rem; 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 { .total-amount-group {
grid-column: 1 / -1; grid-column: 1 / -1;
margin: 0; margin: 0;
+19
View File
@@ -30,4 +30,23 @@ describe('normalizeInvoiceData', () => {
{ mnozstvi: 'not-a-number', cenaZaMj: 10 } { mnozstvi: 'not-a-number', cenaZaMj: 10 }
] })).toBeNull(); ] })).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();
});
}); });