Author SHA1 Message Date
odweta 879d8c7df5 Merge remote-tracking branch 'origin/main' into odweta-urban-goggles
# Conflicts:
#	app/src/app.js
2026-09-10 17:26:45 +02:00
odwetaandCopilot App d73eb10416 Add optional signature upload to invoice PDF
Adds a drag-and-drop/click upload field on the invoice editor for an
optional 420x210 JPG/PNG signature image. The signature is stored as
part of invoice_data and rendered on the generated PDF directly above
the QR code, right-aligned with sensible margins.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:25:37 +02:00
odweta eb48faa6b6 Merge pull request #6 from odweta/odweta-ideal-spork
Allow user-configurable invoice numbering start
2026-09-10 17:23:57 +02:00
odweta 4a2b59f45e Merge pull request #4 from odweta/odweta-align-pdf-payment-footer
Anchor PDF payment card to footer and size QR to payment lines
2026-09-10 17:07:03 +02:00
odwetaandCopilot App 51e0182399 Anchor PDF payment card to footer and size QR to text height
Move the payment info card (account number, IBAN, SWIFT) to the
bottom of the invoice page so it sits flush with the footer, and
size the QR code to match the combined height of the three payment
lines instead of a fixed size.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:06:13 +02:00
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
odweta 5882a957ad Merge pull request #3 from odweta/odweta-fictional-barnacle
Add CI/CD GitHub Actions workflow for dev image deploys
2026-09-10 16:06:28 +02:00
odwetaandCopilot App f4d2c30493 Add CI/CD GitHub Actions workflow for dev image builds
- Run tests on push/PR to main
- Build and push Docker image to GHCR as :dev and :dev-<sha> on push to main
- Document image pull/run instructions in README

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 16:05:06 +02:00
odweta 3848ab47df Merge pull request #2 from odweta/odweta-invoice-pdf-styling
Unify manual and ZIP-exported invoice PDF styling
2026-09-10 16:01:24 +02:00
odwetaandCopilot App 21c23061a4 Unify manual and ZIP invoice PDF styling
The create-screen "print" action used a separate browser-rendered HTML
template while ZIP exports used a PDFKit-rendered PDF, so the two looked
different. Extract the ZIP export's PDF rendering into a shared
renderInvoicePdf function, add a POST /api/invoices/pdf endpoint that
renders the current editor form data through it, and have the print
button download that PDF instead of opening a print window with the old
HTML template.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 16:01:08 +02:00
odweta 84657b9610 Merge pull request #1 from odweta/odweta-invoice-due-dates
Remove custom invoice due date option
2026-09-10 15:59:39 +02:00
9 changed files with 498 additions and 163 deletions
+85
View File
@@ -0,0 +1,85 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build-and-push:
name: Build and push image (dev)
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=dev
type=sha,prefix=dev-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Summary
run: |
echo "### Dev image published :rocket:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
+29 -1
View File
@@ -19,4 +19,32 @@ docker compose up --build
Then open `http://localhost:3001` and create an account. PostgreSQL data is kept Then open `http://localhost:3001` and create an account. PostgreSQL data is kept
in the `postgres-data` Docker volume, so it survives app and container restarts. in the `postgres-data` Docker volume, so it survives app and container restarts.
To remove the database as well, run `docker compose down -v`. To remove the database as well, run `docker compose down -v`.
## CI/CD
On every push to `main`, GitHub Actions (`.github/workflows/ci-cd.yml`) runs the
test suite, then builds and publishes a dev image to the GitHub Container
Registry:
```
ghcr.io/odweta/fakturovac:dev
```
A commit-pinned tag (`ghcr.io/odweta/fakturovac:dev-<short-sha>`) is published
alongside `:dev` for traceability. Pull requests only run the test job.
To pull and run the latest dev image in a testing environment:
```bash
docker login ghcr.io -u <your-github-username> # only needed if the package is private
docker pull ghcr.io/odweta/fakturovac:dev
docker run -d -p 3001:3001 \
-e DATABASE_URL=postgres://fakturovac:fakturovac@<db-host>:5432/fakturovac \
-e NODE_ENV=production \
ghcr.io/odweta/fakturovac:dev
```
The package's visibility (public/private) is managed under the repository's
**Packages** settings on GitHub; no extra secrets are required since the
workflow authenticates with the built-in `GITHUB_TOKEN`.
+85 -14
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
@@ -481,7 +516,7 @@ const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => {
].filter(Boolean).join('*'); ].filter(Boolean).join('*');
}; };
const appendInvoicePdf = async (archive, invoice) => { const renderInvoicePdf = async (document, invoice) => {
const data = invoice.invoice_data || {}; const data = invoice.invoice_data || {};
const supplier = data.supplier || {}; const supplier = data.supplier || {};
const customer = data.customer || {}; const customer = data.customer || {};
@@ -492,7 +527,6 @@ const appendInvoicePdf = async (archive, invoice) => {
const customerName = customer.typSubjektu === 'spolecnost' const customerName = customer.typSubjektu === 'spolecnost'
? customer.nazevSpolecnosti ? customer.nazevSpolecnosti
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(' '); : [customer.jmeno, customer.prijmeni].filter(Boolean).join(' ');
const document = new PDFDocument({ size: 'A4', margin: 50 });
const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'; const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number); const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number);
@@ -501,14 +535,13 @@ const appendInvoicePdf = async (archive, 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 blue = '#2b6f8f';
const line = '#d8d4cc'; const line = '#d8d4cc';
const lightBlue = '#f4f7fb'; const lightBlue = '#f4f7fb';
const warm = '#fffaf0'; const warm = '#fffaf0';
const bodyText = '#17202a'; const bodyText = '#17202a';
const muted = '#65717d';
const address = (profile) => [ const address = (profile) => [
profile.uliceCp, profile.uliceCp,
[profile.psc, profile.mesto].filter(Boolean).join(', ') [profile.psc, profile.mesto].filter(Boolean).join(', ')
@@ -521,7 +554,6 @@ const appendInvoicePdf = async (archive, 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;
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
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, {
@@ -576,20 +608,59 @@ const appendInvoicePdf = async (archive, 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();
}; };
const appendInvoicePdf = async (archive, invoice) => {
const document = new PDFDocument({ size: 'A4', margin: 50 });
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
await renderInvoicePdf(document, invoice);
};
app.post('/api/invoices/pdf', requireAuth, async (req, res, next) => {
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const invoiceNumber = String(req.body.invoiceNumber || 'Nová faktura').trim() || 'Nová faktura';
const document = new PDFDocument({ size: 'A4', margin: 50 });
res.attachment(`${invoiceNumber}.pdf`);
document.pipe(res);
await renderInvoicePdf(document, { invoice_number: invoiceNumber, invoice_data: data });
} catch (error) {
next(error);
}
});
app.get('/api/invoices/export', requireAuth, async (req, res, next) => { app.get('/api/invoices/export', requireAuth, async (req, res, next) => {
try { try {
const result = await pool.query( const result = await pool.query(
+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>
+164 -148
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 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");
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",
@@ -247,6 +346,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");
@@ -273,6 +373,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();
@@ -294,6 +396,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));
@@ -519,10 +623,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 +696,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 {
@@ -625,8 +764,6 @@ const formatCzechNumber = (value) => Number(value || 0).toLocaleString("cs-CZ",
maximumFractionDigits: 2 maximumFractionDigits: 2
}); });
const getTotalValue = () => Number.parseFloat(getValue("total").replace(",", ".")) || 0;
const updateTotal = () => { const updateTotal = () => {
const total = Array.from(itemsContainer.querySelectorAll(".invoice-item")) const total = Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.reduce((sum, item) => { .reduce((sum, item) => {
@@ -638,153 +775,32 @@ const updateTotal = () => {
document.getElementById("total").textContent = `${formatCzechNumber(total)}`; document.getElementById("total").textContent = `${formatCzechNumber(total)}`;
}; };
const getPersonName = (prefix) => [ printInvoiceButton.addEventListener("click", async () => {
getValue(`${prefix}Jmeno`), try {
getValue(`${prefix}Prijmeni`) const response = await fetch("/api/invoices/pdf", {
].filter(Boolean).join(" "); method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: readInvoiceData(),
invoiceNumber: invoiceNumberDisplay.textContent
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Generování faktury se nepodařilo.");
}
const getAddress = (prefix) => [ const blob = await response.blob();
getValue(`${prefix}UliceCp`), const downloadUrl = URL.createObjectURL(blob);
[getValue(`${prefix}Psc`), getValue(`${prefix}Mesto`)].filter(Boolean).join(" ") const link = document.createElement("a");
].filter(Boolean).join(", "); link.href = downloadUrl;
link.download = `${invoiceNumberDisplay.textContent}.pdf`;
const renderPerson = (prefix) => [ link.click();
((prefix === "odberatel" && getValue("odberatelTypSubjektu") === "spolecnost") URL.revokeObjectURL(downloadUrl);
|| (prefix === "dodavatel" && getValue("dodavatelTypSubjektu") === "spolecnost")) } catch (error) {
? getValue(`${prefix}NazevSpolecnosti`) window.alert(error.message);
: getPersonName(prefix),
getValue(`${prefix}Ico`) ? `IČO: ${getValue(`${prefix}Ico`)}` : "",
getAddress(prefix)
].filter(Boolean).map(escapeHtml).join("<br>");
const renderItems = () => Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.map((item) => {
const values = Array.from(item.querySelectorAll("input")).map((input) => input.value.trim());
return `<tr class="invoice-row">
<td>${escapeHtml(values[0])}</td>
<td>${escapeHtml(values[1] ? formatCzechNumber(values[1]) : "0,00")}</td>
<td>${escapeHtml(values[2])}</td>
<td>${escapeHtml(values[3] ? formatCzechNumber(values[3]) : "0,00")} Kč</td>
</tr>`;
}).join("");
const normalizeIban = (value) => value.replace(/\s+/g, "").toUpperCase();
const domesticAccountToIban = (accountNumber) => {
const match = accountNumber.replace(/\s+/g, "")
.match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/);
if (!match) {
return "";
} }
const bban = `${match[3]}${(match[1] || "").padStart(6, "0")}${match[2].padStart(10, "0")}`;
const remainder = `${bban}123500`.split("").reduce(
(value, digit) => (value * 10 + Number(digit)) % 97,
0
);
const checkDigits = String(98 - remainder).padStart(2, "0");
return `CZ${checkDigits}${bban}`;
};
const buildPaymentQrPayload = (accountNumber, iban, swift, total, invoiceNumber = "") => {
const preferredAccount = normalizeIban(accountNumber);
const fallbackAccount = normalizeIban(iban);
const paymentAccount = /^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(preferredAccount)
? preferredAccount
: domesticAccountToIban(accountNumber) || fallbackAccount;
if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(paymentAccount)) {
return "";
}
return [
"SPD*1.0",
`ACC:${paymentAccount}`,
`AM:${Number(total || 0).toFixed(2)}`,
"CC:CZK",
invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, "").slice(-10)}`,
swift && `X-SWIFT:${normalizeIban(swift)}`
].filter(Boolean).join("*");
};
const buildInvoiceHtml = () => {
const accountNumber = getValue("cisloUctu");
const iban = getValue("iban");
const swift = getValue("swift");
const qrPayload = buildPaymentQrPayload(
accountNumber,
iban,
swift,
Number.parseFloat(getValue("total").replace(",", ".")) || 0,
invoiceNumberDisplay.textContent
);
const qrMarkup = qrPayload
? `<img class="qr-code" src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&amp;data=${encodeURIComponent(qrPayload)}" alt="QR kód platebních údajů">`
: "<p>Platební QR kód vyžaduje platný IBAN. Číslo účtu bez IBANu nelze bezpečně převést.</p>";
const paymentDetails = [
accountNumber && `Číslo účtu: ${accountNumber}`,
iban && `IBAN: ${iban}`,
swift && `SWIFT: ${swift}`
].filter(Boolean).map(escapeHtml).join("<br>");
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Faktura</title>
<style>
@page { size: A4; margin: 18mm; }
* { box-sizing: border-box; }
body { color: #202124; font: 14px Arial, sans-serif; margin: 0; }
h1 { font-size: 30px; margin: 0 0 28px; }
h2 { font-size: 16px; margin: 0 0 8px; }
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 28px; }
.party, .payment { border-top: 2px solid #202124; padding-top: 10px; }
table { border-collapse: separate; border-spacing: 0; margin-top: 24px; width: 100%; }
th, td { padding: 9px 6px; text-align: left; }
th { background: #f1f1f1; border-bottom: 1px solid #aeb8c0; }
.invoice-row td { border-bottom: 1px solid #c8c8c8; }
.invoice-row:nth-child(even) { background: #f4f7fb; }
.total { font-size: 18px; font-weight: bold; margin-top: 22px; text-align: right; }
.payment-row { align-items: start; display: flex; gap: 28px; justify-content: space-between; margin-top: 36px; }
.qr-code { height: 180px; width: 180px; }
.qr-placeholder { color: #666; max-width: 220px; }
@media print { .qr-placeholder { color: #202124; } }
</style>
</head>
<body>
<h1>Faktura ${escapeHtml(invoiceNumberDisplay.textContent)}</h1>
<div class="parties">
<section class="party"><h2>Dodavatel</h2>${renderPerson("dodavatel") || "Neuvedeno"}</section>
<section class="party"><h2>Odběratel</h2>${renderPerson("odberatel") || "Neuvedeno"}</section>
</div>
<table>
<thead><tr><th>Položka</th><th>Množství</th><th>MJ</th><th>Cena za MJ</th></tr></thead>
<tbody>${renderItems() || "<tr><td colspan=\"4\">Žádné položky</td></tr>"}</tbody>
</table>
<p class="total">Celková částka: ${formatCzechNumber(getTotalValue())} Kč</p>
<div class="payment-row">
<section class="payment"><h2>Platební údaje</h2>${paymentDetails || "Neuvedeno"}</section>
<div class="qr-placeholder">${qrMarkup}</div>
</div>
</body>
</html>`;
};
printInvoiceButton.addEventListener("click", () => {
const printWindow = window.open("about:blank", "_blank");
if (!printWindow) {
window.alert("Povolte prosím vyskakovací okna pro tisk faktury.");
return;
}
printWindow.document.open();
printWindow.document.write(buildInvoiceHtml());
printWindow.document.close();
printWindow.addEventListener("load", () => printWindow.print(), { once: true });
}); });
const addInvoiceItem = (values = {}) => { const addInvoiceItem = (values = {}) => {
+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();
});
}); });