Author SHA1 Message Date
odwetaandCopilot App 26827904db Center sign-in card content while keeping text left-aligned
The card's content (title, intro, form) hugged the left edge instead of
sitting centered within the wider card. Make .auth-card a centered
flex column so each element centers as a block, while individual
elements keep their own text-align: left. The eyebrow label stays
pinned to the left edge via align-self. The title's two-line layout is
unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:17:46 +02:00
odweta 2cae35bd05 Merge pull request #13 from odweta/odweta-polish-sign-in-page
Widen sign-in card so title fits on two lines
2026-09-10 18:14:15 +02:00
odwetaandCopilot App 310a7c5e0d Widen sign-in card so title reliably fits on two lines
The auth title's two spans could each wrap on wide viewports since the
card was capped at 460px while the heading font scaled up to 3.4rem,
producing three lines instead of two. Widen the card, tune the
heading's clamp, and force each title span to a single line above
420px so it never breaks unpredictably. The form and intro text keep
a comfortable reading width inside the wider card.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:13:18 +02:00
odweta 60be8331c1 Merge pull request #12 from odweta/odweta-single-saved-signature
Store a single saved signature per user, like supplier
2026-09-10 18:07:42 +02:00
odweta 8deec130dc Merge pull request #11 from odweta/odweta-polish-sign-in-page
Polish sign-in page for better visual hierarchy
2026-09-10 18:07:12 +02:00
odwetaandCopilot App bcb6d9df99 Store a single signature per user, like supplier profile
Replace the signature preset dropdown (multiple named signatures) with
a single saved signature per user, mirroring the supplier profile
behavior: Save/Load buttons, auto-loaded on the new invoice screen.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:06:38 +02:00
odwetaandCopilot App 68abd5ccc8 Polish sign-in page for better visual hierarchy
- Force auth title onto two lines consistently for cleaner presentation
- Round card and button corners for a softer, modern look
- Add hover motion and larger touch target on submit button
- Add visible keyboard focus styles for auth controls

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

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:29:19 +02:00
odweta 1c5c074fa1 Merge pull request #7 from odweta/odweta-urban-goggles
Add optional signature upload to invoice PDF
2026-09-10 17:27:55 +02:00
odweta d2d3292a73 Merge pull request #5 from odweta/odweta-decrease-pdf-line-height
Decrease PDF invoice item row height by ~10%
2026-09-10 17:27:36 +02:00
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
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
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
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
odwetaandCopilot App 2bcf9a2f02 Remove custom invoice due date option
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 15:58:43 +02:00
9 changed files with 661 additions and 57 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"
+28
View File
@@ -20,3 +20,31 @@ 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`.
+134 -19
View File
@@ -5,12 +5,12 @@ 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;
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]);
@@ -335,6 +362,43 @@ app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
} }
}); });
app.get('/api/signature', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.json({ signature: result.rowCount ? result.rows[0].data_url : null });
} catch (error) {
next(error);
}
});
app.put('/api/signature', requireAuth, async (req, res, next) => {
try {
const dataUrl = req.body.dataUrl;
if (!isValidSignatureDataUrl(dataUrl)) {
res.status(400).json({ error: 'Podpis musí být platný obrázek JPG nebo PNG.' });
return;
}
const result = await pool.query(`
INSERT INTO signature_profiles (user_id, data_url)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET data_url = EXCLUDED.data_url
RETURNING *
`, [req.userId, dataUrl]);
res.json({ signature: result.rows[0].data_url });
} catch (error) {
next(error);
}
});
app.delete('/api/signature', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const invoiceResponse = (row) => ({ const invoiceResponse = (row) => ({
id: row.id, id: row.id,
invoiceNumber: row.invoice_number, invoiceNumber: row.invoice_number,
@@ -376,13 +440,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
@@ -445,6 +517,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
@@ -500,6 +586,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';
@@ -518,13 +605,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) => {
@@ -539,28 +638,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;
@@ -572,16 +672,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();
}; };
+25
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,
@@ -76,10 +81,30 @@ 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)
);
CREATE TABLE IF NOT EXISTS signature_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data_url TEXT NOT NULL
);
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS 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 '';
ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba';
INSERT INTO signature_profiles (user_id, data_url)
SELECT DISTINCT ON (user_id) user_id, data_url
FROM signature_presets
ORDER BY user_id, created_at DESC
ON CONFLICT (user_id) DO NOTHING;
`).catch((error) => { `).catch((error) => {
schemaPromise = undefined; schemaPromise = undefined;
throw error; throw error;
+11 -1
View File
@@ -1,3 +1,8 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
const isValidSignatureDataUrl = (value) => typeof value === 'string'
&& SIGNATURE_DATA_URL_PATTERN.test(value);
const normalizeInvoiceData = (data) => { const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) { if (!data || !Array.isArray(data.items)) {
return null; return null;
@@ -16,13 +21,18 @@ const normalizeInvoiceData = (data) => {
return null; return null;
} }
const signature = isValidSignatureDataUrl(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)
}; };
}; };
module.exports = { normalizeInvoiceData }; module.exports = { isValidSignatureDataUrl, normalizeInvoiceData };
+46 -12
View File
@@ -10,8 +10,11 @@
<section id="auth-screen" aria-labelledby="auth-title"> <section id="auth-screen" aria-labelledby="auth-title">
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">FAKTUROVAČ</p> <p class="eyebrow">FAKTUROVAČ</p>
<h1 id="auth-title">Vaše faktury, bezpečně po ruce</h1> <h1 id="auth-title">
<p class="auth-intro">Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.</p> <span>Vaše faktury,</span>
<span>bezpečně po ruce.</span>
</h1>
<p class="auth-intro">Spravujte faktury přehledně.</p>
<form id="auth-form"> <form id="auth-form">
<div class="form-group"> <div class="form-group">
<label for="authEmail">E-mail</label> <label for="authEmail">E-mail</label>
@@ -65,11 +68,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>
@@ -308,18 +328,32 @@
<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>
<option value="30">30 dní</option>
<option value="custom">Vlastní počet dní</option>
</select>
</div> </div>
<div class="form-group custom-due-days-group" hidden> <div class="form-group">
<label for="customDueDays">Počet dní</label> <label for="dueDate">Datum splatnosti</label>
<input id="customDueDays" type="number" min="1" step="1" value="14" /> <input type="date" id="dueDate" name="dueDate" placeholder="14 dní od vystavení" />
</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" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" hidden />
<p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p>
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div>
<div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</div>
<div class="saved-data-actions">
<button type="button" id="saveSignatureButton">Uložit podpis</button>
<button type="button" id="loadSignatureButton" class="secondary-button">Načíst uložený</button>
<span id="signatureStatus" class="signature-status" role="status" aria-live="polite"></span>
</div> </div>
<output id="dueDateDisplay" class="due-date-display"></output>
</div> </div>
</div> </div>
+201 -18
View File
@@ -40,13 +40,25 @@ 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 dueDateOption = document.getElementById("dueDateOption"); const numberingSettingsButton = document.getElementById("numberingSettingsButton");
const customDueDays = document.getElementById("customDueDays"); const numberingSettingsPanel = document.getElementById("numberingSettingsPanel");
const customDueDaysGroup = document.querySelector(".custom-due-days-group"); const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumber");
const dueDateDisplay = document.getElementById("dueDateDisplay"); const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
const issueDateInput = document.getElementById("issueDate");
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 saveSignatureButton = document.getElementById("saveSignatureButton");
const loadSignatureButton = document.getElementById("loadSignatureButton");
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton"); const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -70,6 +82,129 @@ 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.removeAttribute("src");
signatureDropzoneText.hidden = false;
removeSignatureButton.hidden = true;
}
};
const saveSignature = async () => {
if (!signatureDataUrl) {
setSignatureStatus("Nejprve nahrajte podpis.", true);
return;
}
try {
await apiRequest("/api/signature", {
method: "PUT",
body: JSON.stringify({ dataUrl: signatureDataUrl })
});
setSignatureStatus("Podpis byl uložen.");
} catch (error) {
setSignatureStatus(error.message, true);
}
};
const loadSignature = async () => {
const { signature } = await apiRequest("/api/signature");
if (signature) {
applySignaturePreview(signature);
setSignatureStatus("Uloženo na serveru");
return true;
}
setSignatureStatus("Zatím není uložený podpis");
return false;
};
const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
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 = "";
});
saveSignatureButton.addEventListener("click", saveSignature);
loadSignatureButton.addEventListener("click", async () => {
try {
await loadSignature();
setSignatureStatus("Podpis načten.");
} catch (error) {
setSignatureStatus(error.message, true);
}
});
removeSignatureButton.addEventListener("click", (event) => {
event.stopPropagation();
applySignaturePreview(null);
setSignatureStatus("");
});
const profileFieldMap = { const profileFieldMap = {
nazevSpolecnosti: "NazevSpolecnosti", nazevSpolecnosti: "NazevSpolecnosti",
@@ -110,20 +245,21 @@ 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 = () => dueDateOption.value === "custom" const toDateInputValue = (date) => [
? Math.max(1, Number.parseInt(customDueDays.value, 10) || 14) date.getFullYear(),
: Number.parseInt(dueDateOption.value, 10); String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
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 = () => {
customDueDaysGroup.hidden = dueDateOption.value !== "custom";
dueDateDisplay.textContent = `Splatnost: ${formatCzechDate(getDueDate())}`;
}; };
const updateClientTypeFields = () => { const updateClientTypeFields = () => {
@@ -239,6 +375,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
@@ -252,6 +390,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");
@@ -268,6 +407,8 @@ const clearInvoiceEditor = () => {
currentInvoiceId = null; currentInvoiceId = null;
invoiceNumberDisplay.textContent = "Nová faktura"; invoiceNumberDisplay.textContent = "Nová faktura";
invoiceSaveStatus.textContent = ""; invoiceSaveStatus.textContent = "";
issueDateInput.value = toDateInputValue(new Date());
dueDateInput.value = "";
writeProfileFields("odberatel", {}); writeProfileFields("odberatel", {});
clientSelect.value = ""; clientSelect.value = "";
clientTypeSelect.value = "osoba"; clientTypeSelect.value = "osoba";
@@ -278,6 +419,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();
@@ -287,6 +430,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();
@@ -299,6 +444,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));
@@ -336,8 +483,9 @@ const loadInvoices = async () => {
renderInvoiceList(); renderInvoiceList();
}; };
const openNewInvoice = () => { const openNewInvoice = async () => {
clearInvoiceEditor(); clearInvoiceEditor();
await loadSignature();
showInvoiceScreen("editor"); showInvoiceScreen("editor");
}; };
@@ -524,10 +672,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(), loadSignature(), 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) {
@@ -580,6 +745,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 {
+104 -5
View File
@@ -33,14 +33,19 @@ body {
} }
.auth-card { .auth-card {
width: min(100%, 460px); display: flex;
padding: clamp(1.75rem, 5vw, 3.5rem); flex-direction: column;
align-items: center;
width: min(100%, 760px);
padding: clamp(1.75rem, 4vw, 3.25rem) clamp(1.75rem, 6vw, 4.5rem);
background: var(--paper); background: var(--paper);
border: 1px solid rgba(24, 59, 86, 0.12); border: 1px solid rgba(24, 59, 86, 0.12);
border-radius: 1rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.eyebrow { .eyebrow {
align-self: flex-start;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
color: var(--blue); color: var(--blue);
font-size: 0.75rem; font-size: 0.75rem;
@@ -52,12 +57,24 @@ body {
margin: 0; margin: 0;
color: var(--navy); color: var(--navy);
font-family: Georgia, serif; font-family: Georgia, serif;
font-size: clamp(2rem, 6vw, 3.4rem); font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 500; font-weight: 500;
line-height: 1.05; line-height: 1.1;
}
.auth-card h1 span {
display: block;
white-space: nowrap;
}
@media (max-width: 420px) {
.auth-card h1 span {
white-space: normal;
}
} }
.auth-intro { .auth-intro {
max-width: 480px;
margin: 1.25rem 0 2rem; margin: 1.25rem 0 2rem;
color: var(--muted-ink); color: var(--muted-ink);
line-height: 1.6; line-height: 1.6;
@@ -66,6 +83,7 @@ body {
#auth-form { #auth-form {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
width: min(100%, 420px);
} }
#auth-form .form-group { #auth-form .form-group {
@@ -74,18 +92,21 @@ body {
#authSubmitButton { #authSubmitButton {
margin-top: 0.5rem; margin-top: 0.5rem;
min-height: 3rem;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
color: #fff; color: #fff;
background: var(--navy); background: var(--navy);
border: 0; border: 0;
border-radius: 0.25rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-weight: 700; font-weight: 700;
transition: background-color 160ms ease, transform 160ms ease;
} }
#authSubmitButton:hover:not(:disabled) { #authSubmitButton:hover:not(:disabled) {
background: var(--blue); background: var(--blue);
transform: translateY(-1px);
} }
.text-button { .text-button {
@@ -99,6 +120,12 @@ body {
text-align: left; text-align: left;
} }
.text-button:focus-visible,
#authSubmitButton:focus-visible {
outline: 3px solid rgba(43, 111, 143, 0.3);
outline-offset: 3px;
}
.text-button:hover { .text-button:hover {
color: var(--navy); color: var(--navy);
text-decoration: underline; text-decoration: underline;
@@ -595,6 +622,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 +654,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;
+26 -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', () => {
@@ -30,4 +30,29 @@ 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();
});
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);
});
}); });