Author SHA1 Message Date
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
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
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
odweta 82a6bc20f9 v2 2026-09-10 15:53:08 +02:00
14 changed files with 3581 additions and 244 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"
+44 -3
View File
@@ -1,9 +1,50 @@
# Fakturovac
A simple app for creating and managing invoices
A simple app for creating and managing invoices.
The app uses PostgreSQL for accounts, sessions, supplier profiles, and clients.
Supplier and client data is private to the signed-in account.
To deploy, run:
```
make rebuild
```
make deploy
```
Or start the stack directly from this directory:
```bash
docker compose up --build
```
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.
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`.
+5 -1
View File
@@ -1,4 +1,4 @@
FROM node:latest
FROM node:22-bookworm-slim
WORKDIR /app
@@ -6,6 +6,10 @@ COPY package.json package-lock.json ./
RUN npm ci
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
COPY . .
CMD ["npm", "run", "serve"]
+29 -1
View File
@@ -1,6 +1,34 @@
name: fakturovac
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: fakturovac
POSTGRES_USER: fakturovac
POSTGRES_PASSWORD: fakturovac
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fakturovac -d fakturovac"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres-data:/var/lib/postgresql/data
app:
build: .
environment:
DATABASE_URL: postgres://fakturovac:fakturovac@db:5432/fakturovac
NODE_ENV: production
SESSION_COOKIE_SECURE: "false"
depends_on:
db:
condition: service_healthy
ports:
- "3001:3001"
- "3001:3001"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
+776 -26
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -10,9 +10,12 @@
"serve": "node src/server.js"
},
"dependencies": {
"archiver": "^8.0.0",
"express": "5.2.1",
"node-fetch": "3.3.2",
"pg": "^8.11.3"
"pdfkit": "^0.20.2",
"pg": "^8.11.3",
"qrcode": "^1.5.4"
},
"devDependencies": {
"jest": "30.5.1",
+714
View File
@@ -1,7 +1,716 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { pool, initializeDatabase } = require('./db');
const { normalizeInvoiceData } = require('./invoice-data');
const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
app.use(express.json({ limit: '2mb' }));
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(`${salt}:${derivedKey.toString('hex')}`);
});
});
const verifyPassword = async (password, storedHash) => {
const [salt, expectedHex] = storedHash.split(':');
const actualHash = await hashPassword(password, salt);
const actualBuffer = Buffer.from(actualHash.split(':')[1], 'hex');
const expectedBuffer = Buffer.from(expectedHex, 'hex');
return actualBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(actualBuffer, expectedBuffer);
};
const getSessionToken = (req) => {
const cookie = req.headers.cookie || '';
const sessionCookie = cookie.split(';').map((part) => part.trim())
.find((part) => part.startsWith('fakturovac_session='));
return sessionCookie ? decodeURIComponent(sessionCookie.split('=').slice(1).join('=')) : '';
};
const setSessionCookie = (res, token) => {
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
res.setHeader('Set-Cookie', `fakturovac_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionDurationMs / 1000}${secure}`);
};
const clearSessionCookie = (res) => {
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
res.setHeader('Set-Cookie', `fakturovac_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
};
const createSession = async (userId) => {
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'30 days\')',
[token, userId]
);
return token;
};
const requireAuth = async (req, res, next) => {
try {
await initializeDatabase();
const token = getSessionToken(req);
if (!token) {
res.status(401).json({ error: 'Nejste přihlášeni.' });
return;
}
const result = await pool.query(
'SELECT user_id FROM sessions WHERE id = $1 AND expires_at > NOW()',
[token]
);
if (!result.rowCount) {
clearSessionCookie(res);
res.status(401).json({ error: 'Relace vypršela.' });
return;
}
req.userId = result.rows[0].user_id;
next();
} catch (error) {
next(error);
}
};
const validateCredentials = (email, password) => typeof email === 'string'
&& email.includes('@')
&& typeof password === 'string'
&& password.length >= 8;
const profileFields = ['nazevSpolecnosti', 'ico', 'jmeno', 'prijmeni', 'uliceCp', 'psc', 'mesto'];
const profileValues = (body) => profileFields.map((field) => typeof body[field] === 'string' ? body[field].trim() : '');
const profileResponse = (row) => ({
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
nazevSpolecnosti: row.nazev_spolecnosti,
ico: row.ico,
jmeno: row.jmeno,
prijmeni: row.prijmeni,
uliceCp: row.ulice_cp,
psc: row.psc,
mesto: row.mesto
});
const clientResponse = (row) => ({
id: row.id,
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
data: profileResponse(row)
});
app.post('/api/auth/register', async (req, res, next) => {
try {
await initializeDatabase();
const email = String(req.body.email || '').trim().toLowerCase();
const password = req.body.password;
if (!validateCredentials(email, password)) {
res.status(400).json({ error: 'Zadejte platný e-mail a heslo dlouhé alespoň 8 znaků.' });
return;
}
const passwordHash = await hashPassword(password);
const result = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email, passwordHash]
);
const token = await createSession(result.rows[0].id);
setSessionCookie(res, token);
res.status(201).json({ user: result.rows[0] });
} catch (error) {
if (error.code === '23505') {
res.status(409).json({ error: 'Účet s tímto e-mailem už existuje.' });
return;
}
next(error);
}
});
app.post('/api/auth/login', async (req, res, next) => {
try {
await initializeDatabase();
const email = String(req.body.email || '').trim().toLowerCase();
const result = await pool.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
if (!result.rowCount || !(await verifyPassword(req.body.password || '', result.rows[0].password_hash))) {
res.status(401).json({ error: 'E-mail nebo heslo není správně.' });
return;
}
const token = await createSession(result.rows[0].id);
setSessionCookie(res, token);
res.json({ user: { id: result.rows[0].id, email: result.rows[0].email } });
} catch (error) {
next(error);
}
});
app.post('/api/auth/logout', async (req, res, next) => {
try {
await initializeDatabase();
const token = getSessionToken(req);
if (token) {
await pool.query('DELETE FROM sessions WHERE id = $1', [token]);
}
clearSessionCookie(res);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get('/api/auth/me', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT id, email FROM users WHERE id = $1', [req.userId]);
res.json({ user: result.rows[0] });
} catch (error) {
next(error);
}
});
app.post('/api/auth/password', requireAuth, async (req, res, next) => {
try {
const currentPassword = req.body.currentPassword || '';
const newPassword = req.body.newPassword || '';
const result = await pool.query('SELECT password_hash FROM users WHERE id = $1', [req.userId]);
if (!result.rowCount || !(await verifyPassword(currentPassword, result.rows[0].password_hash))) {
res.status(400).json({ error: 'Současné heslo není správně.' });
return;
}
if (!validateCredentials('account@example.com', newPassword)) {
res.status(400).json({ error: 'Nové heslo musí mít alespoň 8 znaků.' });
return;
}
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(newPassword), req.userId]);
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [req.userId, getSessionToken(req)]);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get('/api/supplier', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM supplier_profiles WHERE user_id = $1', [req.userId]);
res.json({ supplier: result.rowCount ? profileResponse(result.rows[0]) : null });
} catch (error) {
next(error);
}
});
app.put('/api/supplier', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
INSERT INTO supplier_profiles (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id) DO UPDATE SET
typ_subjektu = EXCLUDED.typ_subjektu, nazev_spolecnosti = EXCLUDED.nazev_spolecnosti,
ico = EXCLUDED.ico,
jmeno = EXCLUDED.jmeno, prijmeni = EXCLUDED.prijmeni,
ulice_cp = EXCLUDED.ulice_cp, psc = EXCLUDED.psc, mesto = EXCLUDED.mesto
RETURNING *
`, [req.userId, type, ...values]);
res.json({ supplier: profileResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
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) => {
try {
const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]);
res.json({ clients: result.rows.map(clientResponse) });
} catch (error) {
next(error);
}
});
app.post('/api/clients', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
INSERT INTO clients (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *
`, [req.userId, type, ...values]);
res.status(201).json({ client: clientResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.put('/api/clients/:id', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
UPDATE clients SET typ_subjektu = $1, nazev_spolecnosti = $2, ico = $3, jmeno = $4,
prijmeni = $5, ulice_cp = $6, psc = $7, mesto = $8
WHERE id = $9 AND user_id = $10 RETURNING *
`, [type, ...values, req.params.id, req.userId]);
if (!result.rowCount) {
res.status(404).json({ error: 'Odběratel nebyl nalezen.' });
return;
}
res.json({ client: clientResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/clients/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM clients WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const paymentPresetResponse = (row) => ({
id: row.id,
name: row.name,
accountNumber: row.account_number,
iban: row.iban,
swift: row.swift
});
app.get('/api/payment-presets', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM payment_presets WHERE user_id = $1 ORDER BY name',
[req.userId]
);
res.json({ presets: result.rows.map(paymentPresetResponse) });
} catch (error) {
next(error);
}
});
app.post('/api/payment-presets', requireAuth, async (req, res, next) => {
try {
const name = String(req.body.name || '').trim();
if (!name) {
res.status(400).json({ error: 'Zadejte název platební konfigurace.' });
return;
}
const result = await pool.query(`
INSERT INTO payment_presets (user_id, name, account_number, iban, swift)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, name) DO UPDATE SET
account_number = EXCLUDED.account_number,
iban = EXCLUDED.iban,
swift = EXCLUDED.swift
RETURNING *
`, [
req.userId,
name,
String(req.body.accountNumber || '').trim(),
String(req.body.iban || '').trim(),
String(req.body.swift || '').trim()
]);
res.status(201).json({ preset: paymentPresetResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM payment_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const invoiceResponse = (row) => ({
id: row.id,
invoiceNumber: row.invoice_number,
data: row.invoice_data,
createdAt: row.created_at,
updatedAt: row.updated_at
});
app.get('/api/invoices', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT id, invoice_number, invoice_data, created_at, updated_at FROM invoices WHERE user_id = $1 ORDER BY updated_at DESC, id DESC',
[req.userId]
);
res.json({ invoices: result.rows.map(invoiceResponse) });
} catch (error) {
next(error);
}
});
app.get('/health', async (req, res, next) => {
try {
await initializeDatabase();
await pool.query('SELECT 1');
res.json({ status: 'ok' });
} catch (error) {
next(error);
}
});
app.post('/api/invoices', requireAuth, async (req, res, next) => {
const client = await pool.connect();
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const invoiceYear = new Date().getFullYear();
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(`
INSERT INTO invoice_counters (user_id, invoice_year, last_number)
SELECT $1, $2, GREATEST(
COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0),
$4 - 1
)
FROM invoices
WHERE user_id = $1 AND invoice_number LIKE $3
ON CONFLICT (user_id, invoice_year) DO NOTHING
`, [req.userId, invoiceYear, `F-${invoiceYear}-%`, invoiceStartingNumber]);
const counterResult = await client.query(`
UPDATE invoice_counters
SET last_number = last_number + 1
WHERE user_id = $1 AND invoice_year = $2
RETURNING last_number
`, [req.userId, invoiceYear]);
const invoiceNumber = `F-${invoiceYear}-${String(counterResult.rows[0].last_number).padStart(4, '0')}`;
const result = await client.query(
'INSERT INTO invoices (user_id, invoice_number, invoice_data) VALUES ($1, $2, $3) RETURNING *',
[req.userId, invoiceNumber, data]
);
await client.query('COMMIT');
res.status(201).json({ invoice: invoiceResponse(result.rows[0]) });
} catch (error) {
await client.query('ROLLBACK');
next(error);
} finally {
client.release();
}
});
app.put('/api/invoices/:id', requireAuth, async (req, res, next) => {
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const result = await pool.query(
'UPDATE invoices SET invoice_data = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING *',
[data, req.params.id, req.userId]
);
if (!result.rowCount) {
res.status(404).json({ error: 'Faktura nebyla nalezena.' });
return;
}
res.json({ invoice: invoiceResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/invoices/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM invoices WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.delete('/api/invoices', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM invoices WHERE user_id = $1', [req.userId]);
await pool.query('DELETE FROM invoice_counters WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
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', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const normalizeIban = (value) => String(value || '').replace(/\s+/g, '').toUpperCase();
const domesticAccountToIban = (accountNumber) => {
const match = String(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
);
return `CZ${String(98 - remainder).padStart(2, '0')}${bban}`;
};
const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => {
const account = normalizeIban(payment.iban) || domesticAccountToIban(payment.cisloUctu);
if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(account)) {
return '';
}
return [
'SPD*1.0',
`ACC:${account}`,
`AM:${Number(total || 0).toFixed(2)}`,
'CC:CZK',
invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, '').slice(-10)}`,
payment.swift && `X-SWIFT:${normalizeIban(payment.swift)}`
].filter(Boolean).join('*');
};
const renderInvoicePdf = async (document, invoice) => {
const data = invoice.invoice_data || {};
const supplier = data.supplier || {};
const customer = data.customer || {};
const payment = data.payment || {};
const supplierName = supplier.typSubjektu === 'spolecnost'
? supplier.nazevSpolecnosti
: [supplier.jmeno, supplier.prijmeni].filter(Boolean).join(' ');
const customerName = customer.typSubjektu === 'spolecnost'
? customer.nazevSpolecnosti
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(' ');
const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number);
const qrBuffer = qrPayload ? await QRCode.toBuffer(qrPayload, {
type: 'png',
width: 180,
margin: 1
}) : 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 navy = '#183b56';
const line = '#d8d4cc';
const lightBlue = '#f4f7fb';
const warm = '#fffaf0';
const bodyText = '#17202a';
const address = (profile) => [
profile.uliceCp,
[profile.psc, profile.mesto].filter(Boolean).join(', ')
].filter(Boolean).join(', ');
const identity = (profile, name) => [
name || 'Neuvedeno',
profile.ico ? `IČO: ${profile.ico}` : '',
address(profile)
].filter(Boolean);
const partyWidth = (pageWidth - 24) / 2;
const customerX = document.page.margins.left + partyWidth + 24;
const issueDate = resolveIssueDate(data.issueDate);
const dueDate = resolveDueDate(data.dueDate, data.issueDate);
const headerY = document.y;
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, {
width: partyWidth,
align: 'right'
});
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 drawParty = (x, title, profile, name) => {
const lines = identity(profile, name);
document.roundedRect(x, partyTop, partyWidth, 86, 4).fillAndStroke('#f4f7fb', line);
document.fillColor(navy).font(boldFont).fontSize(11).text(title, x + 10, partyTop + 10, { width: partyWidth - 20 });
document.fillColor(bodyText).font(regularFont).fontSize(9).text(lines.join('\n'), x + 10, partyTop + 30, { width: partyWidth - 20, lineGap: 3 });
};
drawParty(document.page.margins.left, 'Dodavatel', supplier, supplierName);
drawParty(customerX, 'Odběratel', customer, customerName);
document.y = partyTop + 100;
const tableTop = document.y + 12;
const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth];
const headerRowHeight = 25;
const itemRowHeight = 22.5;
document.rect(document.page.margins.left, tableTop, pageWidth, headerRowHeight).fill('#eef1f3');
document.fillColor(navy).font(boldFont).fontSize(9);
['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => {
document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, {
width: columns[index + 1] - columns[index] - 12
});
});
document.y = tableTop + headerRowHeight;
(data.items || []).forEach((item, index) => {
const rowTop = document.y;
document.rect(document.page.margins.left, rowTop, pageWidth, itemRowHeight)
.fill(index % 2 ? lightBlue : warm);
document.moveTo(document.page.margins.left, rowTop + itemRowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + itemRowHeight)
.strokeColor(line).lineWidth(0.7).stroke();
document.fillColor(bodyText).font(regularFont).fontSize(9);
[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 + 6, {
width: columns[columnIndex + 1] - columns[columnIndex] - 12
}));
document.y = rowTop + itemRowHeight;
});
document.y += 18;
document.fillColor(navy).font(boldFont).fontSize(15)
.text(`Celková částka: ${formatCzechNumber(data.total)}`, document.page.margins.left, document.y, {
width: pageWidth,
align: 'right'
});
document.y += 35;
document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y)
.strokeColor(navy).lineWidth(1.5).stroke();
const paymentCardHeight = 105;
const paymentTop = document.page.height - document.page.margins.bottom - paymentCardHeight;
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(bodyText).font(regularFont).fontSize(10)
.text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35)
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight * 2);
if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - qrSize - 10, paymentTop + 30, {
width: qrSize,
height: qrSize
});
}
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) => {
try {
const result = await pool.query(
'SELECT invoice_number, invoice_data FROM invoices WHERE user_id = $1 ORDER BY invoice_number',
[req.userId]
);
if (!result.rowCount) {
res.status(404).json({ error: 'Nejsou uložené žádné faktury k exportu.' });
return;
}
res.attachment('faktury.zip');
const archive = new archiver.ZipArchive({ zlib: { level: 9 } });
archive.on('error', next);
archive.pipe(res);
for (const invoice of result.rows) {
await appendInvoicePdf(archive, invoice);
}
archive.finalize();
} catch (error) {
next(error);
}
});
// specifying the folder with the static content - html+css
app.use(express.static(__dirname + '/static'));
@@ -10,4 +719,9 @@ app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '/static/index.html'));
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'Na serveru došlo k chybě.' });
});
module.exports = app;
+97
View File
@@ -0,0 +1,97 @@
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgres://fakturovac:fakturovac@localhost:5432/fakturovac"
});
let schemaPromise;
const initializeDatabase = () => {
if (!schemaPromise) {
schemaPromise = pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS supplier_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
typ_subjektu TEXT NOT NULL DEFAULT 'osoba',
nazev_spolecnosti TEXT NOT NULL DEFAULT '',
ico TEXT NOT NULL DEFAULT '',
jmeno TEXT NOT NULL DEFAULT '',
prijmeni TEXT NOT NULL DEFAULT '',
ulice_cp TEXT NOT NULL DEFAULT '',
psc TEXT NOT NULL DEFAULT '',
mesto TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS clients (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
typ_subjektu TEXT NOT NULL DEFAULT 'osoba',
nazev_spolecnosti TEXT NOT NULL DEFAULT '',
ico TEXT NOT NULL DEFAULT '',
jmeno TEXT NOT NULL DEFAULT '',
prijmeni TEXT NOT NULL DEFAULT '',
ulice_cp TEXT NOT NULL DEFAULT '',
psc TEXT NOT NULL DEFAULT '',
mesto TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invoices (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_number TEXT NOT NULL,
invoice_data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, invoice_number)
);
CREATE TABLE IF NOT EXISTS invoice_counters (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_year INTEGER NOT NULL,
last_number INTEGER NOT NULL DEFAULT 0,
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 (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
account_number TEXT NOT NULL DEFAULT '',
iban TEXT NOT NULL DEFAULT '',
swift TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, name)
);
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT '';
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS 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 typ_subjektu TEXT NOT NULL DEFAULT 'osoba';
`).catch((error) => {
schemaPromise = undefined;
throw error;
});
}
return schemaPromise;
};
module.exports = { pool, initializeDatabase };
+35
View File
@@ -0,0 +1,35 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) {
return null;
}
const items = data.items.map((item) => ({
popis: typeof item.popis === 'string' ? item.popis.trim() : '',
mnozstvi: Number(item.mnozstvi),
mernaJednotka: typeof item.mernaJednotka === 'string' ? item.mernaJednotka.trim() : '',
cenaZaMj: Number(item.cenaZaMj)
}));
if (items.some((item) => !Number.isFinite(item.mnozstvi)
|| !Number.isFinite(item.cenaZaMj)
|| item.mnozstvi < 0
|| item.cenaZaMj < 0)) {
return null;
}
const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature)
? data.signature
: null;
return {
supplier: data.supplier || {},
customer: data.customer || {},
payment: data.payment || {},
items,
signature,
total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0)
};
};
module.exports = { normalizeInvoiceData };
+9 -3
View File
@@ -1,7 +1,13 @@
const app = require('./app');
const { initializeDatabase } = require('./db');
const port = 3001;
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
initializeDatabase()
.then(() => app.listen(port, () => {
console.log(`App listening on port ${port}`);
}))
.catch((error) => {
console.error('Unable to initialize database', error);
process.exit(1);
});
+232 -19
View File
@@ -7,10 +7,123 @@
<title>Fakturovač</title>
</head>
<body>
<main id="main-container">
<div>
<section id="auth-screen" aria-labelledby="auth-title">
<div class="auth-card">
<p class="eyebrow">FAKTUROVAČ</p>
<h1 id="auth-title">Vaše faktury, bezpečně po ruce</h1>
<p class="auth-intro">Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.</p>
<form id="auth-form">
<div class="form-group">
<label for="authEmail">E-mail</label>
<input id="authEmail" type="email" autocomplete="email" required />
</div>
<div class="form-group">
<label for="authPassword">Heslo</label>
<input id="authPassword" type="password" minlength="8" autocomplete="current-password" required />
</div>
<button type="submit" id="authSubmitButton">Přihlásit se</button>
<button type="button" id="authModeButton" class="text-button">Nemáte účet? Zaregistrovat se</button>
<button type="button" id="forgotPasswordButton" class="text-button">Zapomenuté heslo?</button>
<p id="authStatus" class="auth-status" role="alert" aria-live="polite"></p>
</form>
</div>
</section>
<main id="main-container" hidden>
<nav class="top-navigation" aria-label="Hlavní navigace">
<details class="nav-menu">
<summary>Faktury</summary>
<div class="nav-menu-content">
<button type="button" id="invoiceListNavButton" class="nav-button">
<span class="menu-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
</span>
Seznam faktur
</button>
<button type="button" id="newInvoiceButton" class="nav-button">
<span class="menu-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</span>
Nová faktura
</button>
<button type="button" id="exportInvoicesButton" class="nav-button">
<span class="menu-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg>
</span>
Export PDF (ZIP)
</button>
<button type="button" id="deleteAllInvoicesButton" class="nav-button danger-nav-button">
<span class="menu-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M19 6l-1 14H6L5 6"></path><path d="M10 11v5"></path><path d="M14 11v5"></path></svg>
</span>
Smazat všechny a resetovat číslování
</button>
</div>
</details>
<span id="currentViewLabel" class="current-view-label">Seznam faktur</span>
<details class="nav-menu nav-account">
<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">
<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>
</div>
</details>
</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>
<div id="passwordForm">
<h3>Změnit heslo</h3>
<div class="password-fields">
<div class="form-group">
<label for="currentPassword">Současné heslo</label>
<input id="currentPassword" type="password" autocomplete="current-password" required />
</div>
<div class="form-group">
<label for="newPassword">Nové heslo</label>
<input id="newPassword" type="password" minlength="8" autocomplete="new-password" required />
</div>
</div>
<div class="saved-data-actions">
<button type="button" id="savePasswordButton">Uložit nové heslo</button>
<button type="button" id="cancelPasswordButton" class="secondary-button">Zrušit</button>
<span id="passwordStatus" role="status" aria-live="polite"></span>
</div>
</div>
</section>
<section id="invoice-list-screen" class="invoice-list-screen">
<div class="screen-heading">
<div>
<p class="eyebrow">ARCHIV</p>
<h1>Vaše faktury</h1>
</div>
</div>
<div id="invoiceList" class="invoice-list"></div>
</section>
<div id="invoice-editor-screen" hidden>
<form id="main-form">
<h2>Fakturovač</h2>
<div class="invoice-meta">
<span id="invoiceNumberDisplay">Nová faktura</span>
</div>
<!--
ico
jmeno
@@ -30,7 +143,7 @@
----------
polozky
+ popis
+ položka
+ mnozstvi
+ merna jednotka MJ
+ cena za MJ
@@ -43,19 +156,34 @@
<div id="dodavatelWrapper" class="form-part">
<h3>Dodavatel</h3>
<div class="form-group">
<label for="dodavatelTypSubjektu">Typ dodavatele</label>
<select id="dodavatelTypSubjektu">
<option value="osoba">Jméno a příjmení</option>
<option value="spolecnost">Společnost</option>
</select>
</div>
<div class="form-group">
<label for="dodavatelIco">IČO</label>
<input id="dodavatelIco" name="dodavatelIco" />
</div>
<div class="form-group">
<label for="dodavatelJmeno">Jméno</label>
<input id="dodavatelJmeno" name="dodavatelJmeno" />
<div id="supplierPersonFields" class="person-fields">
<div class="form-group">
<label for="dodavatelJmeno">Jméno</label>
<input id="dodavatelJmeno" name="dodavatelJmeno" />
</div>
<div class="form-group">
<label for="dodavatelPrijmeni">Příjmení</label>
<input id="dodavatelPrijmeni" name="dodavatelPrijmeni" />
</div>
</div>
<div class="form-group">
<label for="dodavatelPrijmeni">Příjmení</label>
<input id="dodavatelPrijmeni" name="dodavatelPrijmeni" />
<div id="supplierCompanyFields" class="form-group" hidden>
<label for="dodavatelNazevSpolecnosti">Název společnosti</label>
<input id="dodavatelNazevSpolecnosti" name="dodavatelNazevSpolecnosti" />
</div>
<div class="form-group">
@@ -72,24 +200,52 @@
<label for="dodavatelMesto">Město</label>
<input id="dodavatelMesto" name="dodavatelMesto" />
</div>
<div class="saved-data-actions">
<button type="button" id="saveSupplierButton">Uložit dodavatele</button>
<button type="button" id="loadSupplierButton" class="secondary-button">Načíst uloženého</button>
<span id="supplierSaveStatus" role="status" aria-live="polite"></span>
</div>
</div>
<div id="odberatelWrapper" class="form-part">
<h3>Odběratel</h3>
<div class="form-group">
<label for="clientSelect">Uložený odběratel</label>
<select id="clientSelect">
<option value="">Vyberte odběratele</option>
</select>
</div>
<div class="form-group">
<label for="odberatelIco">IČO</label>
<input id="odberatelIco" name="odberatelIco" />
</div>
<div class="form-group">
<label for="odberatelJmeno">Jméno</label>
<input id="odberatelJmeno" name="odberatelJmeno" />
<label for="odberatelTypSubjektu">Typ odběratele</label>
<select id="odberatelTypSubjektu">
<option value="osoba">Jméno a příjmení</option>
<option value="spolecnost">Společnost</option>
</select>
</div>
<div class="form-group">
<label for="odberatelPrijmeni">Příjmení</label>
<input id="odberatelPrijmeni" name="odberatelPrijmeni" />
<div id="personFields" class="person-fields">
<div class="form-group">
<label for="odberatelJmeno">Jméno</label>
<input id="odberatelJmeno" name="odberatelJmeno" />
</div>
<div class="form-group">
<label for="odberatelPrijmeni">Příjmení</label>
<input id="odberatelPrijmeni" name="odberatelPrijmeni" />
</div>
</div>
<div id="companyFields" class="form-group" hidden>
<label for="odberatelNazevSpolecnosti">Název společnosti</label>
<input id="odberatelNazevSpolecnosti" name="odberatelNazevSpolecnosti" />
</div>
<div class="form-group">
@@ -106,6 +262,13 @@
<label for="odberatelMesto">Město</label>
<input id="odberatelMesto" name="odberatelMesto" />
</div>
<div class="saved-data-actions client-actions">
<button type="button" id="saveClientButton">Uložit odběratele</button>
<button type="button" id="deleteClientButton" class="secondary-button" disabled>
Smazat vybraného
</button>
</div>
</div>
</div>
@@ -118,7 +281,27 @@
</div>
<div class="form-chapter third-row">
<div class="form-part">
<div class="payment-preset-column">
<div class="form-group">
<label for="paymentPresetSelect">Uložené platební údaje</label>
<select id="paymentPresetSelect">
<option value="">Vyberte konfiguraci</option>
</select>
</div>
<div class="form-group payment-preset-name">
<label for="paymentPresetName">Název platebních údajů</label>
<input id="paymentPresetName" type="text" placeholder="Např. hlavní účet" />
</div>
<div class="payment-preset-actions">
<button type="button" id="savePaymentPresetButton">Uložení platebních údajů</button>
<button type="button" id="loadPaymentPresetButton">Načtení platebních údajů</button>
<button type="button" id="deletePaymentPresetButton" class="secondary-button" disabled>Smazat</button>
</div>
</div>
<div class="payment-account-column">
<div class="form-group">
<label for="cisloUctu">Číslo účtu</label>
<input id="cisloUctu" name="cisloUctu" />
@@ -135,15 +318,45 @@
</div>
</div>
<div class="form-group">
<label for="total">Celková částka</label>
<output id="total" name="total">0.00</output>
<div class="form-group total-amount-group">
<label for="total">Celková částka (CZK)</label>
<output id="total" name="total">0.00</output>
</div>
<div class="due-date-group">
<div class="form-group">
<label for="issueDate">Datum vystavení</label>
<input type="date" id="issueDate" name="issueDate" placeholder="Dnešní datum" />
</div>
<div class="form-group">
<label for="dueDate">Datum splatnosti</label>
<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" 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">
Vygenerovat a vytisknout fakturu
</button>
<button type="button" id="saveInvoiceButton" class="save-invoice-button">
Uložit fakturu
</button>
<p id="invoiceSaveStatus" class="invoice-save-status" role="status" aria-live="polite"></p>
</form>
</div>
</main>
+797 -144
View File
@@ -1,8 +1,758 @@
const addItemButton = document.getElementById("addItemButton");
const itemsContainer = document.getElementById("itemsContainer");
const printInvoiceButton = document.getElementById("printInvoiceButton");
const saveInvoiceButton = document.getElementById("saveInvoiceButton");
const invoiceSaveStatus = document.getElementById("invoiceSaveStatus");
const invoiceNumberDisplay = document.getElementById("invoiceNumberDisplay");
const invoiceList = document.getElementById("invoiceList");
const invoiceListScreen = document.getElementById("invoice-list-screen");
const invoiceEditorScreen = document.getElementById("invoice-editor-screen");
const invoiceListNavButton = document.getElementById("invoiceListNavButton");
const newInvoiceButton = document.getElementById("newInvoiceButton");
const exportInvoicesButton = document.getElementById("exportInvoicesButton");
const deleteAllInvoicesButton = document.getElementById("deleteAllInvoicesButton");
const currentViewLabel = document.getElementById("currentViewLabel");
const saveSupplierButton = document.getElementById("saveSupplierButton");
const loadSupplierButton = document.getElementById("loadSupplierButton");
const supplierSaveStatus = document.getElementById("supplierSaveStatus");
const clientSelect = document.getElementById("clientSelect");
const clientTypeSelect = document.getElementById("odberatelTypSubjektu");
const personFields = document.getElementById("personFields");
const companyFields = document.getElementById("companyFields");
const supplierTypeSelect = document.getElementById("dodavatelTypSubjektu");
const supplierPersonFields = document.getElementById("supplierPersonFields");
const supplierCompanyFields = document.getElementById("supplierCompanyFields");
const saveClientButton = document.getElementById("saveClientButton");
const deleteClientButton = document.getElementById("deleteClientButton");
const authScreen = document.getElementById("auth-screen");
const authForm = document.getElementById("auth-form");
const authEmail = document.getElementById("authEmail");
const authPassword = document.getElementById("authPassword");
const authSubmitButton = document.getElementById("authSubmitButton");
const authModeButton = document.getElementById("authModeButton");
const forgotPasswordButton = document.getElementById("forgotPasswordButton");
const authStatus = document.getElementById("authStatus");
const logoutButton = document.getElementById("logoutButton");
const signedInEmail = document.getElementById("navEmail");
const passwordButton = document.getElementById("passwordButton");
const passwordPanel = document.getElementById("passwordPanel");
const passwordForm = document.getElementById("passwordForm");
const savePasswordButton = document.getElementById("savePasswordButton");
const cancelPasswordButton = document.getElementById("cancelPasswordButton");
const passwordStatus = document.getElementById("passwordStatus");
const numberingSettingsButton = document.getElementById("numberingSettingsButton");
const numberingSettingsPanel = document.getElementById("numberingSettingsPanel");
const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumber");
const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
const issueDateInput = document.getElementById("issueDate");
const dueDateInput = document.getElementById("dueDate");
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName");
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 deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
document.querySelectorAll(".nav-menu button").forEach((button) => {
button.addEventListener("click", () => {
button.closest("details").open = false;
});
});
document.addEventListener("click", (event) => {
document.querySelectorAll(".nav-menu[open]").forEach((menu) => {
if (!menu.contains(event.target)) {
menu.open = false;
}
});
});
let itemIndex = 0;
let clients = [];
let invoices = [];
let paymentPresets = [];
let currentInvoiceId = null;
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 = {
nazevSpolecnosti: "NazevSpolecnosti",
ico: "Ico",
jmeno: "Jmeno",
prijmeni: "Prijmeni",
uliceCp: "UliceCp",
psc: "Psc",
mesto: "Mesto"
};
const readProfileFields = (prefix) => Object.fromEntries(
Object.entries(profileFieldMap).map(([key, suffix]) => [
key,
document.getElementById(`${prefix}${suffix}`).value.trim()
])
);
const writeProfileFields = (prefix, fields) => Object.entries(profileFieldMap).forEach(([key, suffix]) => {
const field = document.getElementById(`${prefix}${suffix}`);
if (field) {
field.value = fields[key] || "";
}
});
const writeFields = (fields) => Object.entries(fields).forEach(([id, value]) => {
const field = document.getElementById(id);
if (field) {
field.value = value || "";
}
});
const getClientLabel = (client) => [
client.data.nazevSpolecnosti,
[client.data.jmeno, client.data.prijmeni].filter(Boolean).join(" "),
client.data.ico && `IČO: ${client.data.ico}`
].filter(Boolean).join(" ") || "Bez názvu";
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
const toDateInputValue = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
const getIssueDate = () => issueDateInput.value || toDateInputValue(new Date());
const getDueDate = () => {
if (dueDateInput.value) {
return dueDateInput.value;
}
const dueDate = new Date(`${getIssueDate()}T00:00:00`);
dueDate.setDate(dueDate.getDate() + 14);
return toDateInputValue(dueDate);
};
const updateClientTypeFields = () => {
const isCompany = clientTypeSelect.value === "spolecnost";
personFields.hidden = isCompany;
companyFields.hidden = !isCompany;
};
const updateSupplierTypeFields = () => {
const isCompany = supplierTypeSelect.value === "spolecnost";
supplierPersonFields.hidden = isCompany;
supplierCompanyFields.hidden = !isCompany;
};
const apiRequest = async (url, options = {}) => {
const response = await fetch(url, {
...options,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
const data = response.status === 204 ? null : await response.json();
if (!response.ok) {
throw new Error(data?.error || "Požadavek se nepodařilo dokončit.");
}
return data;
};
const showInvoiceScreen = (screen) => {
invoiceListScreen.hidden = screen !== "list";
invoiceEditorScreen.hidden = screen !== "editor";
currentViewLabel.textContent = screen === "list"
? "Seznam faktur"
: (currentInvoiceId ? "Upravit fakturu" : "Nová faktura");
};
const renderPaymentPresetOptions = (selectedId = paymentPresetSelect.value) => {
paymentPresetSelect.innerHTML = "<option value=\"\">Vyberte konfiguraci</option>";
paymentPresets.forEach((preset) => {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = preset.name;
paymentPresetSelect.appendChild(option);
});
paymentPresetSelect.value = selectedId;
deletePaymentPresetButton.disabled = !paymentPresetSelect.value;
};
const loadPaymentPresets = async () => {
const response = await apiRequest("/api/payment-presets");
paymentPresets = response.presets;
renderPaymentPresetOptions();
};
paymentPresetSelect.addEventListener("change", () => {
const preset = paymentPresets.find(({ id }) => String(id) === paymentPresetSelect.value);
if (!preset) {
deletePaymentPresetButton.disabled = true;
return;
}
paymentPresetName.value = preset.name;
deletePaymentPresetButton.disabled = false;
});
loadPaymentPresetButton.addEventListener("click", () => {
const preset = paymentPresets.find(({ id }) => String(id) === paymentPresetSelect.value);
if (!preset) {
window.alert("Nejprve vyberte uložené platební údaje.");
return;
}
document.getElementById("cisloUctu").value = preset.accountNumber;
document.getElementById("iban").value = preset.iban;
document.getElementById("swift").value = preset.swift;
});
savePaymentPresetButton.addEventListener("click", async () => {
try {
const response = await apiRequest("/api/payment-presets", {
method: "POST",
body: JSON.stringify({
name: paymentPresetName.value,
accountNumber: getValue("cisloUctu"),
iban: getValue("iban"),
swift: getValue("swift")
})
});
const preset = response.preset;
paymentPresets = [
...paymentPresets.filter((entry) => entry.id !== preset.id && entry.name !== preset.name),
preset
].sort((left, right) => left.name.localeCompare(right.name));
renderPaymentPresetOptions(String(preset.id));
paymentPresetName.value = preset.name;
} catch (error) {
window.alert(error.message);
}
});
deletePaymentPresetButton.addEventListener("click", async () => {
if (!paymentPresetSelect.value) {
return;
}
try {
await apiRequest(`/api/payment-presets/${paymentPresetSelect.value}`, { method: "DELETE" });
paymentPresets = paymentPresets.filter(({ id }) => String(id) !== paymentPresetSelect.value);
paymentPresetName.value = "";
renderPaymentPresetOptions();
} catch (error) {
window.alert(error.message);
}
});
const readInvoiceData = () => ({
issueDate: getIssueDate(),
dueDate: getDueDate(),
supplier: {
...readProfileFields("dodavatel"),
typSubjektu: supplierTypeSelect.value
},
customer: {
...readProfileFields("odberatel"),
typSubjektu: clientTypeSelect.value
},
payment: {
cisloUctu: getValue("cisloUctu"),
iban: getValue("iban"),
swift: getValue("swift")
},
signature: signatureDataUrl,
total: Number.parseFloat(getValue("total").replace(",", ".")) || 0,
items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => {
const inputs = item.querySelectorAll("input");
return {
popis: inputs[0].value.trim(),
mnozstvi: Number(inputs[1].value) || 0,
mernaJednotka: inputs[2].value.trim(),
cenaZaMj: Number(inputs[3].value) || 0
};
})
});
const clearInvoiceEditor = () => {
currentInvoiceId = null;
invoiceNumberDisplay.textContent = "Nová faktura";
invoiceSaveStatus.textContent = "";
issueDateInput.value = toDateInputValue(new Date());
dueDateInput.value = "";
writeProfileFields("odberatel", {});
clientSelect.value = "";
clientTypeSelect.value = "osoba";
updateClientTypeFields();
paymentPresetSelect.value = "";
paymentPresetName.value = "";
deletePaymentPresetButton.disabled = true;
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = "";
});
applySignaturePreview(null);
setSignatureStatus("");
itemsContainer.replaceChildren();
itemIndex = 0;
updateTotal();
};
const loadInvoiceIntoEditor = (invoice) => {
currentInvoiceId = invoice.id;
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
invoiceSaveStatus.textContent = "";
issueDateInput.value = invoice.data.issueDate || "";
dueDateInput.value = invoice.data.dueDate || "";
writeProfileFields("dodavatel", invoice.data.supplier || {});
supplierTypeSelect.value = invoice.data.supplier?.typSubjektu || "osoba";
updateSupplierTypeFields();
writeProfileFields("odberatel", invoice.data.customer || {});
clientTypeSelect.value = invoice.data.customer?.typSubjektu || "osoba";
updateClientTypeFields();
paymentPresetSelect.value = "";
paymentPresetName.value = "";
deletePaymentPresetButton.disabled = true;
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = invoice.data.payment?.[id] || "";
});
applySignaturePreview(invoice.data.signature || null);
setSignatureStatus("");
itemsContainer.replaceChildren();
itemIndex = 0;
(invoice.data.items || []).forEach((item) => addInvoiceItem(item));
updateTotal();
showInvoiceScreen("editor");
};
const getInvoiceLabel = (invoice) => {
const customer = invoice.data.customer || {};
return customer.typSubjektu === "spolecnost"
? customer.nazevSpolecnosti || "Bez odběratele"
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(" ") || "Bez odběratele";
};
const renderInvoiceList = () => {
if (!invoices.length) {
invoiceList.innerHTML = "<p class=\"empty-list\">Zatím nemáte uložené žádné faktury.</p>";
return;
}
invoiceList.innerHTML = invoices.map((invoice) => `
<article class="invoice-list-row">
<button type="button" class="invoice-open-button" data-invoice-id="${invoice.id}">
<strong>${escapeHtml(invoice.invoiceNumber)}</strong>
<span>${escapeHtml(getInvoiceLabel(invoice))}</span>
<small>${new Date(invoice.updatedAt).toLocaleDateString("cs-CZ")}</small>
</button>
<button type="button" class="invoice-delete-button" data-delete-invoice-id="${invoice.id}">Smazat</button>
</article>
`).join("");
};
const loadInvoices = async () => {
const response = await apiRequest("/api/invoices");
invoices = response.invoices;
renderInvoiceList();
};
const openNewInvoice = () => {
clearInvoiceEditor();
showInvoiceScreen("editor");
};
const saveInvoice = async () => {
try {
const response = await apiRequest(currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : "/api/invoices", {
method: currentInvoiceId ? "PUT" : "POST",
body: JSON.stringify({ data: readInvoiceData() })
});
const invoice = response.invoice;
currentInvoiceId = invoice.id;
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
invoiceSaveStatus.textContent = "Faktura uložena";
await loadInvoices();
showInvoiceScreen("list");
} catch (error) {
invoiceSaveStatus.textContent = error.message;
}
};
invoiceListNavButton.addEventListener("click", async () => {
await loadInvoices();
showInvoiceScreen("list");
});
newInvoiceButton.addEventListener("click", openNewInvoice);
exportInvoicesButton.addEventListener("click", async () => {
try {
const response = await fetch("/api/invoices/export", { credentials: "same-origin" });
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Export se nepodařil.");
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = "faktury.zip";
link.click();
URL.revokeObjectURL(downloadUrl);
} catch (error) {
window.alert(error.message);
}
});
saveInvoiceButton.addEventListener("click", saveInvoice);
deleteAllInvoicesButton.addEventListener("click", async () => {
if (!invoices.length || !window.confirm("Opravdu chcete smazat všechny faktury a začít číslovat znovu od 0001?")) {
return;
}
try {
await apiRequest("/api/invoices", { method: "DELETE" });
invoices = [];
renderInvoiceList();
clearInvoiceEditor();
showInvoiceScreen("list");
} catch (error) {
window.alert(error.message);
}
});
invoiceList.addEventListener("click", async (event) => {
const openButton = event.target.closest("[data-invoice-id]");
const deleteButton = event.target.closest("[data-delete-invoice-id]");
if (deleteButton) {
if (!window.confirm("Opravdu chcete tuto fakturu smazat?")) {
return;
}
await apiRequest(`/api/invoices/${deleteButton.dataset.deleteInvoiceId}`, { method: "DELETE" });
await loadInvoices();
return;
}
if (openButton) {
const invoice = invoices.find(({ id }) => String(id) === openButton.dataset.invoiceId);
if (invoice) {
loadInvoiceIntoEditor(invoice);
}
}
});
const saveSupplier = async () => {
try {
const data = readProfileFields("dodavatel");
data.typSubjektu = supplierTypeSelect.value;
await apiRequest("/api/supplier", {
method: "PUT",
body: JSON.stringify(data)
});
supplierSaveStatus.textContent = "Uloženo na serveru";
} catch (error) {
supplierSaveStatus.textContent = error.message;
}
};
const loadSupplier = async () => {
const { supplier } = await apiRequest("/api/supplier");
if (supplier) {
writeProfileFields("dodavatel", supplier);
supplierTypeSelect.value = supplier.typSubjektu || (supplier.nazevSpolecnosti ? "spolecnost" : "osoba");
updateSupplierTypeFields();
supplierSaveStatus.textContent = "Uloženo na serveru";
return true;
}
supplierSaveStatus.textContent = "Zatím není uložený dodavatel";
return false;
};
const renderClientOptions = (selectedId = clientSelect.value) => {
clientSelect.innerHTML = "<option value=\"\">Vyberte odběratele</option>";
clients.forEach((client) => {
const option = document.createElement("option");
option.value = client.id;
option.textContent = getClientLabel(client);
clientSelect.appendChild(option);
});
clientSelect.value = selectedId;
deleteClientButton.disabled = !clientSelect.value;
};
const loadClients = async () => {
const response = await apiRequest("/api/clients");
clients = response.clients;
renderClientOptions();
};
const loadSelectedClient = () => {
const client = clients.find(({ id }) => String(id) === clientSelect.value);
if (client) {
writeProfileFields("odberatel", client.data);
clientTypeSelect.value = client.typSubjektu || (client.data.nazevSpolecnosti ? "spolecnost" : "osoba");
updateClientTypeFields();
}
deleteClientButton.disabled = !client;
};
saveSupplierButton.addEventListener("click", saveSupplier);
supplierTypeSelect.addEventListener("change", updateSupplierTypeFields);
loadSupplierButton.addEventListener("click", async () => {
try {
await loadSupplier();
supplierSaveStatus.textContent = "Dodavatel načten";
} catch (error) {
supplierSaveStatus.textContent = error.message;
}
});
clientTypeSelect.addEventListener("change", updateClientTypeFields);
clientSelect.addEventListener("change", loadSelectedClient);
saveClientButton.addEventListener("click", async () => {
const data = readProfileFields("odberatel");
data.typSubjektu = clientTypeSelect.value;
const existingClient = clients.find((client) => String(client.id) === clientSelect.value);
try {
const response = await apiRequest(existingClient ? `/api/clients/${existingClient.id}` : "/api/clients", {
method: existingClient ? "PUT" : "POST",
body: JSON.stringify(data)
});
const client = response.client;
clients = existingClient
? clients.map((entry) => entry.id === client.id ? client : entry)
: [...clients, client];
renderClientOptions(String(client.id));
} catch (error) {
window.alert(error.message);
}
});
deleteClientButton.addEventListener("click", async () => {
if (!clientSelect.value) {
return;
}
try {
await apiRequest(`/api/clients/${clientSelect.value}`, { method: "DELETE" });
clients = clients.filter(({ id }) => String(id) !== clientSelect.value);
renderClientOptions();
} catch (error) {
window.alert(error.message);
}
});
const showInvoiceApp = async () => {
authScreen.hidden = true;
document.getElementById("main-container").hidden = false;
await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadNumberingSettings()]);
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) => {
passwordPanel.hidden = !visible;
if (!visible) {
passwordForm.querySelectorAll("input").forEach((input) => {
input.value = "";
});
passwordStatus.textContent = "";
}
};
const setAuthMode = (registrationMode) => {
isRegistrationMode = registrationMode;
authSubmitButton.textContent = registrationMode ? "Vytvořit účet" : "Přihlásit se";
authModeButton.textContent = registrationMode
? "Máte účet? Přihlásit se"
: "Nemáte účet? Zaregistrovat se";
authPassword.autocomplete = registrationMode ? "new-password" : "current-password";
authStatus.textContent = "";
};
authModeButton.addEventListener("click", () => setAuthMode(!isRegistrationMode));
forgotPasswordButton.addEventListener("click", () => {
authStatus.textContent = "Obnova hesla e-mailem zatím není nakonfigurovaná. Přihlaste se a použijte Změnit heslo, nebo kontaktujte správce aplikace.";
});
authForm.addEventListener("submit", async (event) => {
event.preventDefault();
authStatus.textContent = "";
authSubmitButton.disabled = true;
try {
const { user } = await apiRequest(isRegistrationMode ? "/api/auth/register" : "/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: authEmail.value, password: authPassword.value })
});
signedInEmail.textContent = user.email;
await showInvoiceApp();
} catch (error) {
authStatus.textContent = error.message;
} finally {
authSubmitButton.disabled = false;
}
});
logoutButton.addEventListener("click", async () => {
await apiRequest("/api/auth/logout", { method: "POST" });
window.location.reload();
});
passwordButton.addEventListener("click", () => showPasswordPanel(passwordPanel.hidden));
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 () => {
passwordStatus.textContent = "";
try {
await apiRequest("/api/auth/password", {
method: "POST",
body: JSON.stringify({
currentPassword: document.getElementById("currentPassword").value,
newPassword: document.getElementById("newPassword").value
})
});
passwordForm.querySelectorAll("input").forEach((input) => {
input.value = "";
});
passwordStatus.textContent = "Heslo bylo změněno";
} catch (error) {
passwordStatus.textContent = error.message;
}
});
(async () => {
try {
const { user } = await apiRequest("/api/auth/me");
signedInEmail.textContent = user.email;
await showInvoiceApp();
} catch {
authScreen.hidden = false;
document.getElementById("main-container").hidden = true;
}
})();
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
@@ -13,9 +763,17 @@ const escapeHtml = (value) => String(value ?? "")
const getValue = (id) => {
const field = document.getElementById(id);
if (!field) {
return "";
}
return ("value" in field ? field.value : field.textContent).trim();
};
const formatCzechNumber = (value) => Number(value || 0).toLocaleString("cs-CZ", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const updateTotal = () => {
const total = Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.reduce((sum, item) => {
@@ -24,154 +782,38 @@ const updateTotal = () => {
return sum + quantity * unitPrice;
}, 0);
document.getElementById("total").textContent = total.toFixed(2);
document.getElementById("total").textContent = `${formatCzechNumber(total)}`;
};
const getPersonName = (prefix) => [
getValue(`${prefix}Jmeno`),
getValue(`${prefix}Prijmeni`)
].filter(Boolean).join(" ");
printInvoiceButton.addEventListener("click", async () => {
try {
const response = await fetch("/api/invoices/pdf", {
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) => [
getValue(`${prefix}UliceCp`),
[getValue(`${prefix}Psc`), getValue(`${prefix}Mesto`)].filter(Boolean).join(" ")
].filter(Boolean).join(", ");
const renderPerson = (prefix) => [
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])}</td>
<td>${escapeHtml(values[2])}</td>
<td>${escapeHtml(values[3])}</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 blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${invoiceNumberDisplay.textContent}.pdf`;
link.click();
URL.revokeObjectURL(downloadUrl);
} catch (error) {
window.alert(error.message);
}
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) => {
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",
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,
getValue("total")
);
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</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>Popis</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: ${escapeHtml(getValue("total")) || "0"}</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 });
});
addItemButton.addEventListener("click", () => {
const addInvoiceItem = (values = {}) => {
itemIndex++;
const item = document.createElement("div");
@@ -179,7 +821,7 @@ addItemButton.addEventListener("click", () => {
item.innerHTML = `
<div class="form-group">
<label for="polozka${itemIndex}Popis">Popis</label>
<label for="polozka${itemIndex}Popis">Položka</label>
<input
id="polozka${itemIndex}Popis"
name="polozky[${itemIndex}][popis]"
@@ -250,6 +892,12 @@ addItemButton.addEventListener("click", () => {
</button>
`;
const inputs = item.querySelectorAll("input");
inputs[0].value = values.popis || "";
inputs[1].value = values.mnozstvi ?? "";
inputs[2].value = values.mernaJednotka || "";
inputs[3].value = values.cenaZaMj ?? "";
const deleteButton = item.querySelector(".delete-item-button");
deleteButton.addEventListener("click", () => {
@@ -259,6 +907,11 @@ addItemButton.addEventListener("click", () => {
itemsContainer.appendChild(item);
updateTotal();
});
};
addItemButton.addEventListener("click", () => addInvoiceItem());
itemsContainer.addEventListener("input", updateTotal);
updateClientTypeFields();
updateSupplierTypeFields();
+708 -4
View File
@@ -22,6 +22,94 @@ body {
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
}
[hidden] {
display: none !important;
}
#auth-screen {
display: grid;
min-height: calc(100vh - 6rem);
place-items: center;
}
.auth-card {
width: min(100%, 460px);
padding: clamp(1.75rem, 5vw, 3.5rem);
background: var(--paper);
border: 1px solid rgba(24, 59, 86, 0.12);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 0.75rem;
color: var(--blue);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.16em;
}
.auth-card h1 {
margin: 0;
color: var(--navy);
font-family: Georgia, serif;
font-size: clamp(2rem, 6vw, 3.4rem);
font-weight: 500;
line-height: 1.05;
}
.auth-intro {
margin: 1.25rem 0 2rem;
color: var(--muted-ink);
line-height: 1.6;
}
#auth-form {
display: grid;
gap: 1rem;
}
#auth-form .form-group {
margin: 0;
}
#authSubmitButton {
margin-top: 0.5rem;
padding: 0.8rem 1rem;
color: #fff;
background: var(--navy);
border: 0;
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-weight: 700;
}
#authSubmitButton:hover:not(:disabled) {
background: var(--blue);
}
.text-button {
padding: 0;
color: var(--blue);
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: left;
}
.text-button:hover {
color: var(--navy);
text-decoration: underline;
}
.auth-status {
min-height: 1.25rem;
margin: 0;
color: #a53c36;
}
#main-container {
width: min(100%, 980px);
margin: 0 auto;
@@ -31,6 +119,303 @@ body {
box-shadow: var(--shadow);
}
.top-navigation {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
margin-bottom: 2rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--line);
}
.nav-menu {
position: relative;
}
.nav-menu summary {
list-style: none;
padding: 0.65rem 0.9rem;
color: var(--navy);
background: transparent;
border: 1px solid var(--blue);
border-radius: 0.25rem;
cursor: pointer;
font-weight: 700;
}
.nav-menu summary::-webkit-details-marker {
display: none;
}
.nav-menu summary::after {
margin-left: 0.5rem;
content: "▾";
}
.nav-menu[open] summary::after {
content: "▴";
}
.nav-menu-content {
position: absolute;
z-index: 2;
top: calc(100% + 0.45rem);
left: 0;
display: grid;
min-width: 20rem;
gap: 0.35rem;
padding: 0.55rem;
background: var(--paper);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.nav-menu-content .nav-button,
.nav-menu-content .text-button {
width: 100%;
white-space: nowrap;
text-align: left;
}
.menu-icon {
display: inline-block;
width: 1.35rem;
margin-right: 0.35rem;
text-align: center;
vertical-align: middle;
}
.menu-icon svg {
display: block;
width: 1.05rem;
height: 1.05rem;
margin: 0 auto;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.current-view-label {
color: var(--muted-ink);
font-size: 0.9rem;
font-weight: 700;
}
.nav-account {
margin-left: auto;
}
.nav-account summary {
display: flex;
align-items: center;
gap: 0.55rem;
max-width: 280px;
}
.nav-account .nav-menu-content {
right: 0;
left: auto;
}
.danger-nav-button {
color: #8c3d36 !important;
border-color: #c47a72 !important;
}
.danger-nav-button:hover {
color: #fff !important;
background: #a53c36 !important;
}
.nav-button,
.primary-action {
padding: 0.65rem 0.9rem;
color: var(--navy);
background: transparent;
border: 1px solid var(--blue);
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-weight: 700;
}
.primary-nav-button,
.primary-action {
color: #fff;
background: var(--navy);
}
.nav-button:hover,
.primary-action:hover {
color: #fff;
background: var(--blue);
}
.nav-account {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
margin-left: auto;
}
.account-mark {
position: relative;
display: inline-block;
width: 1.6rem;
height: 1.6rem;
border: 1px solid var(--blue);
border-radius: 50%;
}
.account-mark::before {
position: absolute;
top: 0.28rem;
left: 0.52rem;
width: 0.42rem;
height: 0.42rem;
background: var(--blue);
border-radius: 50%;
content: "";
}
.account-mark::after {
position: absolute;
bottom: 0.25rem;
left: 0.32rem;
width: 0.8rem;
height: 0.42rem;
background: var(--blue);
border-radius: 0.5rem 0.5rem 0.25rem 0.25rem;
content: "";
}
.invoice-list-screen {
width: 100%;
}
.screen-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.screen-heading h1 {
margin: 0;
color: var(--navy);
font-family: Georgia, serif;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 500;
}
.screen-heading .eyebrow {
margin-bottom: 0.35rem;
}
.invoice-list {
display: grid;
gap: 0.75rem;
}
.invoice-list-row {
display: flex;
align-items: stretch;
gap: 0.75rem;
padding: 0.75rem;
background: #f4f7fb;
border: 1px solid var(--line);
}
.invoice-open-button {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(10rem, 2fr) auto;
align-items: center;
flex: 1;
gap: 1rem;
padding: 0.5rem;
color: var(--ink);
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
text-align: left;
}
.invoice-open-button strong {
color: var(--navy);
}
.invoice-open-button small {
color: var(--muted-ink);
}
.invoice-delete-button {
align-self: center;
padding: 0.55rem 0.75rem;
color: #8c3d36;
background: transparent;
border: 1px solid #c47a72;
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
}
.invoice-delete-button:hover {
color: #fff;
background: #a53c36;
}
.empty-list {
padding: 2.5rem 1rem;
color: var(--muted-ink);
background: #f4f7fb;
border: 1px dashed var(--line);
text-align: center;
}
.invoice-meta {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 820px;
color: var(--muted-ink);
font-size: 0.9rem;
}
.invoice-meta span {
color: var(--navy);
font-weight: 700;
}
.save-invoice-button {
align-self: center;
padding: 0.75rem 1rem;
color: #fff;
background: var(--blue);
border: 0;
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-weight: 700;
}
.save-invoice-button:hover {
background: var(--navy);
}
.invoice-save-status {
min-height: 1.25rem;
margin: -1.25rem 0 0;
color: var(--muted-ink);
text-align: center;
}
#main-form {
display: flex;
flex-direction: column;
@@ -38,8 +423,16 @@ body {
gap: 2rem;
}
#main-form > h2 {
.app-heading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
}
.app-heading h2 {
width: auto;
margin: 0;
color: var(--navy);
font-family: Georgia, serif;
@@ -49,6 +442,109 @@ body {
text-align: center;
}
.app-heading .text-button {
color: var(--muted-ink);
font-size: 0.85rem;
font-weight: 700;
}
.account-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
}
.account-bar {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: 0.75rem;
min-height: 1.5rem;
}
.signed-in-email {
max-width: 220px;
overflow: hidden;
color: var(--muted-ink);
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.password-panel {
width: 100%;
max-width: 820px;
padding: 1rem 1.25rem;
background: #f4f7fb;
border: 1px solid var(--line);
}
.password-panel h3 {
margin: 0 0 1rem;
color: var(--navy);
font-family: Georgia, serif;
font-weight: 500;
}
.password-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.payment-preset-actions {
display: grid;
grid-template-columns: 1fr;
align-items: center;
gap: 0.65rem;
margin-bottom: 1rem;
}
.payment-preset-name {
margin-bottom: 1rem;
}
.payment-preset-actions button {
min-height: 2.6rem;
padding: 0.55rem 0.7rem;
color: var(--navy);
background: transparent;
border: 1px solid var(--blue);
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 700;
white-space: nowrap;
}
.payment-preset-actions button:hover:not(:disabled) {
color: #fff;
background: var(--blue);
}
.payment-preset-actions .secondary-button {
color: #8c3d36;
border-color: #c47a72;
}
.payment-preset-actions .secondary-button:hover:not(:disabled) {
background: #a53c36;
}
.payment-preset-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.payment-preset-column,
.payment-account-column {
min-width: 0;
}
.form-chapter {
display: flex;
flex-direction: row;
@@ -99,6 +595,12 @@ body {
outline-offset: 1px;
}
.field-hint {
margin: 0.35rem 0 0;
color: var(--muted-ink);
font-size: 0.8rem;
}
.form-group output {
min-height: 1.5rem;
padding: 0.25rem 0;
@@ -120,11 +622,82 @@ body {
.third-row {
align-items: flex-start;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2rem;
}
.third-row > .form-part,
.third-row > .form-group {
flex: 1;
.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 {
grid-column: 1 / -1;
margin: 0;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.supplier-customer-section {
@@ -149,6 +722,91 @@ body {
font-weight: 500;
}
select {
width: 100%;
padding: 0.65rem 0.7rem;
color: var(--ink);
background: #fff;
border: 1px solid #c8d0d5;
border-radius: 0.25rem;
font: inherit;
}
select:focus {
border-color: var(--blue);
outline: 2px solid rgba(43, 111, 143, 0.18);
outline-offset: 1px;
}
.person-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.saved-data-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 0.65rem;
margin-top: 0.25rem;
}
.saved-data-actions button {
min-height: 2.6rem;
}
.client-actions,
#dodavatelWrapper > .saved-data-actions {
width: 100%;
}
.client-actions button,
#dodavatelWrapper > .saved-data-actions button {
min-width: 0;
white-space: nowrap;
}
.saved-data-actions > [role="status"] {
grid-column: 1 / -1;
}
.saved-data-actions button {
padding: 0.55rem 0.75rem;
color: var(--navy);
background: transparent;
border: 1px solid var(--blue);
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-size: 0.9rem;
font-weight: 700;
}
.saved-data-actions button:hover:not(:disabled) {
color: #fff;
background: var(--blue);
}
.saved-data-actions .secondary-button {
color: #8c3d36;
border-color: #c47a72;
}
.saved-data-actions .secondary-button:hover:not(:disabled) {
background: #a53c36;
}
.saved-data-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
#supplierSaveStatus {
color: var(--muted-ink);
font-size: 0.85rem;
}
#itemsContainer {
width: 100%;
}
@@ -234,6 +892,7 @@ body {
font: inherit;
font-weight: 700;
padding: 0.6rem 0.85rem;
margin-bottom: 0.25rem;
}
#addItemButton:hover {
@@ -241,6 +900,10 @@ body {
background: var(--blue);
}
#printInvoiceButton {
margin-top: 0.25rem;
}
@media (max-width: 700px) {
body {
padding: 1rem 0.5rem;
@@ -250,11 +913,52 @@ body {
padding: 1.5rem 1rem;
}
.top-navigation,
.nav-account {
flex-wrap: wrap;
}
.nav-account {
justify-content: flex-start;
width: 100%;
margin-left: 0;
}
.screen-heading {
align-items: flex-start;
flex-direction: column;
}
.invoice-open-button {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.supplier-customer-section {
grid-template-columns: 1fr;
row-gap: 1rem;
}
.app-heading {
flex-direction: column;
gap: 0.75rem;
}
.account-bar {
justify-content: center;
flex-wrap: wrap;
}
.password-fields,
.person-fields,
.third-row {
grid-template-columns: 1fr;
}
.total-amount-group {
grid-column: auto;
}
.invoice-item {
grid-template-columns: 1fr;
}
+46 -42
View File
@@ -1,48 +1,52 @@
// const request = require("supertest");
// const app = require("../src/app");
const { normalizeInvoiceData } = require('../src/invoice-data');
// describe("GET /search", () => {
// afterEach(() => {
// jest.restoreAllMocks();
// });
describe('normalizeInvoiceData', () => {
test('calculates the total from validated line items', () => {
const invoice = normalizeInvoiceData({
supplier: {},
customer: {},
payment: {},
total: 999,
items: [
{ popis: 'Práce', mnozstvi: '2', mernaJednotka: 'hod', cenaZaMj: '125.5' },
{ popis: 'Materiál', mnozstvi: 1, mernaJednotka: 'ks', cenaZaMj: 50 }
]
});
// test("returns 400 when query is missing", async () => {
// const response = await request(app).get("/search");
expect(invoice.total).toBe(301);
expect(invoice.items[0]).toEqual({
popis: 'Práce',
mnozstvi: 2,
mernaJednotka: 'hod',
cenaZaMj: 125.5
});
});
// expect(response.status).toBe(400);
// expect(response.text).toBe("Missing search query");
// });
test('rejects negative or non-numeric line item values', () => {
expect(normalizeInvoiceData({ items: [
{ mnozstvi: -1, cenaZaMj: 10 }
] })).toBeNull();
expect(normalizeInvoiceData({ items: [
{ mnozstvi: 'not-a-number', cenaZaMj: 10 }
] })).toBeNull();
});
// test("returns filtered search results", async () => {
// global.fetch = jest.fn().mockResolvedValue({
// json: jest.fn().mockResolvedValue({
// results: [
// {
// url: "https://example.com",
// title: "Example",
// content: "Content",
// irrelevant: "removed"
// }
// ]
// })
// });
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 response = await request(app)
// .get("/search")
// .query({ query: "nodejs" });
const withInvalidSignature = normalizeInvoiceData({
items: [{ mnozstvi: 1, cenaZaMj: 10 }],
signature: 'not-a-data-url'
});
expect(withInvalidSignature.signature).toBeNull();
// expect(response.status).toBe(200);
// expect(response.headers["content-type"]).toMatch(/application\/json/);
// expect(response.headers["content-disposition"]).toContain(
// 'attachment; filename="output.json"'
// );
// expect(response.body).toEqual([
// {
// url: "https://example.com",
// title: "Example",
// content: "Content"
// }
// ]);
// });
// });
const withoutSignature = normalizeInvoiceData({
items: [{ mnozstvi: 1, cenaZaMj: 10 }]
});
expect(withoutSignature.signature).toBeNull();
});
});