Author SHA1 Message Date
odwetaandCopilot App 6eb743a1bf Allow user-configurable invoice numbering start
Add a per-user invoice_starting_number setting (default 1) instead of
always starting invoice numbering at 001. Adds /api/settings endpoints,
a user_settings table, and a Číslování faktur panel in the account menu
where the user can pick the starting number for a year with no
invoices yet.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:04:41 +02:00
5 changed files with 113 additions and 15 deletions
+43 -14
View File
@@ -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
@@ -572,22 +607,16 @@ 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();
const paymentCardHeight = 105; document.y += 12;
const paymentTop = document.page.height - document.page.margins.bottom - paymentCardHeight; const paymentTop = document.y;
const paymentLineHeight = 18; document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line);
const qrSize = paymentLineHeight * 3;
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 + 35 + paymentLineHeight) .text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight * 2); .text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75);
if (qrBuffer) { if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - qrSize - 10, paymentTop + 30, { document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 });
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,
+17
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>
+42 -1
View File
@@ -40,6 +40,12 @@ 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 dueDateOption = document.getElementById("dueDateOption"); const dueDateOption = document.getElementById("dueDateOption");
const dueDateDisplay = document.getElementById("dueDateDisplay"); const dueDateDisplay = document.getElementById("dueDateDisplay");
const paymentPresetSelect = document.getElementById("paymentPresetSelect"); const paymentPresetSelect = document.getElementById("paymentPresetSelect");
@@ -519,10 +525,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) {
@@ -575,6 +598,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 {
+6
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;