Author SHA1 Message Date
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 82a6bc20f9 v2 2026-09-10 15:53:08 +02:00
13 changed files with 3119 additions and 247 deletions
+16 -3
View File
@@ -1,9 +1,22 @@
# Fakturovac # 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: 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`.
+5 -1
View File
@@ -1,4 +1,4 @@
FROM node:latest FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
@@ -6,6 +6,10 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
CMD ["npm", "run", "serve"] CMD ["npm", "run", "serve"]
+29 -1
View File
@@ -1,6 +1,34 @@
name: fakturovac name: fakturovac
services: 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: app:
build: . build: .
environment:
DATABASE_URL: postgres://fakturovac:fakturovac@db:5432/fakturovac
NODE_ENV: production
SESSION_COOKIE_SECURE: "false"
depends_on:
db:
condition: service_healthy
ports: 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" "serve": "node src/server.js"
}, },
"dependencies": { "dependencies": {
"archiver": "^8.0.0",
"express": "5.2.1", "express": "5.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"pg": "^8.11.3" "pdfkit": "^0.20.2",
"pg": "^8.11.3",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"jest": "30.5.1", "jest": "30.5.1",
+636
View File
@@ -1,7 +1,638 @@
const express = require('express'); const express = require('express');
const path = require('path'); 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 app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
app.use(express.json());
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/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');
await client.query(`
INSERT INTO invoice_counters (user_id, invoice_year, last_number)
SELECT $1, $2, COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0)
FROM invoices
WHERE user_id = $1 AND invoice_number LIKE $3
ON CONFLICT (user_id, invoice_year) DO NOTHING
`, [req.userId, invoiceYear, `F-${invoiceYear}-%`]);
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 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 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 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.y = headerY + 40;
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 rowHeight = 25;
document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).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 + rowHeight;
(data.items || []).forEach((item, index) => {
const rowTop = document.y;
document.rect(document.page.margins.left, rowTop, pageWidth, rowHeight)
.fill(index % 2 ? lightBlue : warm);
document.moveTo(document.page.margins.left, rowTop + rowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight)
.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 + 8, {
width: columns[columnIndex + 1] - columns[columnIndex] - 12
}));
document.y = rowTop + rowHeight;
});
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();
document.y += 12;
const paymentTop = document.y;
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 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 + 55)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75);
if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 });
}
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 // specifying the folder with the static content - html+css
app.use(express.static(__dirname + '/static')); app.use(express.static(__dirname + '/static'));
@@ -10,4 +641,9 @@ app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '/static/index.html')); 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; module.exports = app;
+92
View File
@@ -0,0 +1,92 @@
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 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 };
+28
View File
@@ -0,0 +1,28 @@
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;
}
return {
supplier: data.supplier || {},
customer: data.customer || {},
payment: data.payment || {},
items,
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 app = require('./app');
const { initializeDatabase } = require('./db');
const port = 3001; const port = 3001;
app.listen(port, () => { initializeDatabase()
console.log(`App listening on port ${port}`); .then(() => app.listen(port, () => {
}); console.log(`App listening on port ${port}`);
}))
.catch((error) => {
console.error('Unable to initialize database', error);
process.exit(1);
});
+205 -19
View File
@@ -7,10 +7,106 @@
<title>Fakturovač</title> <title>Fakturovač</title>
</head> </head>
<body> <body>
<main id="main-container"> <section id="auth-screen" aria-labelledby="auth-title">
<div> <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="logoutButton" class="text-button">Odhlásit se</button>
</div>
</details>
</nav>
<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"> <form id="main-form">
<h2>Fakturovač</h2> <div class="invoice-meta">
<span id="invoiceNumberDisplay">Nová faktura</span>
</div>
<!-- <!--
ico ico
jmeno jmeno
@@ -30,7 +126,7 @@
---------- ----------
polozky polozky
+ popis + položka
+ mnozstvi + mnozstvi
+ merna jednotka MJ + merna jednotka MJ
+ cena za MJ + cena za MJ
@@ -43,19 +139,34 @@
<div id="dodavatelWrapper" class="form-part"> <div id="dodavatelWrapper" class="form-part">
<h3>Dodavatel</h3> <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"> <div class="form-group">
<label for="dodavatelIco">IČO</label> <label for="dodavatelIco">IČO</label>
<input id="dodavatelIco" name="dodavatelIco" /> <input id="dodavatelIco" name="dodavatelIco" />
</div> </div>
<div class="form-group"> <div id="supplierPersonFields" class="person-fields">
<label for="dodavatelJmeno">Jméno</label> <div class="form-group">
<input id="dodavatelJmeno" name="dodavatelJmeno" /> <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>
<div class="form-group"> <div id="supplierCompanyFields" class="form-group" hidden>
<label for="dodavatelPrijmeni">Příjmení</label> <label for="dodavatelNazevSpolecnosti">Název společnosti</label>
<input id="dodavatelPrijmeni" name="dodavatelPrijmeni" /> <input id="dodavatelNazevSpolecnosti" name="dodavatelNazevSpolecnosti" />
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -72,24 +183,52 @@
<label for="dodavatelMesto">Město</label> <label for="dodavatelMesto">Město</label>
<input id="dodavatelMesto" name="dodavatelMesto" /> <input id="dodavatelMesto" name="dodavatelMesto" />
</div> </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>
<div id="odberatelWrapper" class="form-part"> <div id="odberatelWrapper" class="form-part">
<h3>Odběratel</h3> <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"> <div class="form-group">
<label for="odberatelIco">IČO</label> <label for="odberatelIco">IČO</label>
<input id="odberatelIco" name="odberatelIco" /> <input id="odberatelIco" name="odberatelIco" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="odberatelJmeno">Jméno</label> <label for="odberatelTypSubjektu">Typ odběratele</label>
<input id="odberatelJmeno" name="odberatelJmeno" /> <select id="odberatelTypSubjektu">
<option value="osoba">Jméno a příjmení</option>
<option value="spolecnost">Společnost</option>
</select>
</div> </div>
<div class="form-group"> <div id="personFields" class="person-fields">
<label for="odberatelPrijmeni">Příjmení</label> <div class="form-group">
<input id="odberatelPrijmeni" name="odberatelPrijmeni" /> <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>
<div class="form-group"> <div class="form-group">
@@ -106,6 +245,13 @@
<label for="odberatelMesto">Město</label> <label for="odberatelMesto">Město</label>
<input id="odberatelMesto" name="odberatelMesto" /> <input id="odberatelMesto" name="odberatelMesto" />
</div> </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>
</div> </div>
@@ -118,7 +264,27 @@
</div> </div>
<div class="form-chapter third-row"> <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"> <div class="form-group">
<label for="cisloUctu">Číslo účtu</label> <label for="cisloUctu">Číslo účtu</label>
<input id="cisloUctu" name="cisloUctu" /> <input id="cisloUctu" name="cisloUctu" />
@@ -135,15 +301,35 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group total-amount-group">
<label for="total">Celková částka</label> <label for="total">Celková částka (CZK)</label>
<output id="total" name="total">0.00</output> <output id="total" name="total">0.00</output>
</div>
<div class="due-date-group">
<div class="form-group">
<label for="dueDateOption">Datum splatnosti</label>
<select id="dueDateOption">
<option value="14">14 dní</option>
<option value="30">30 dní</option>
<option value="custom">Vlastní počet dní</option>
</select>
</div>
<div class="form-group custom-due-days-group" hidden>
<label for="customDueDays">Počet dní</label>
<input id="customDueDays" type="number" min="1" step="1" value="14" />
</div>
<output id="dueDateDisplay" class="due-date-display"></output>
</div> </div>
</div> </div>
<button type="button" id="printInvoiceButton"> <button type="button" id="printInvoiceButton">
Vygenerovat a vytisknout fakturu Vygenerovat a vytisknout fakturu
</button> </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> </form>
</div> </div>
</main> </main>
+653 -144
View File
@@ -1,8 +1,614 @@
const addItemButton = document.getElementById("addItemButton"); const addItemButton = document.getElementById("addItemButton");
const itemsContainer = document.getElementById("itemsContainer"); const itemsContainer = document.getElementById("itemsContainer");
const printInvoiceButton = document.getElementById("printInvoiceButton"); 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 dueDateOption = document.getElementById("dueDateOption");
const customDueDays = document.getElementById("customDueDays");
const customDueDaysGroup = document.querySelector(".custom-due-days-group");
const dueDateDisplay = document.getElementById("dueDateDisplay");
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName");
const savePaymentPresetButton = document.getElementById("savePaymentPresetButton");
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 itemIndex = 0;
let clients = [];
let invoices = [];
let paymentPresets = [];
let currentInvoiceId = null;
let isRegistrationMode = false;
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 getDueDays = () => dueDateOption.value === "custom"
? Math.max(1, Number.parseInt(customDueDays.value, 10) || 14)
: Number.parseInt(dueDateOption.value, 10);
const getDueDate = () => {
const dueDate = new Date();
dueDate.setHours(0, 0, 0, 0);
dueDate.setDate(dueDate.getDate() + getDueDays());
return dueDate.toISOString().slice(0, 10);
};
const updateDueDate = () => {
customDueDaysGroup.hidden = dueDateOption.value !== "custom";
dueDateDisplay.textContent = `Splatnost: ${formatCzechDate(getDueDate())}`;
};
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 = () => ({
supplier: {
...readProfileFields("dodavatel"),
typSubjektu: supplierTypeSelect.value
},
customer: {
...readProfileFields("odberatel"),
typSubjektu: clientTypeSelect.value
},
payment: {
cisloUctu: getValue("cisloUctu"),
iban: getValue("iban"),
swift: getValue("swift")
},
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 = "";
writeProfileFields("odberatel", {});
clientSelect.value = "";
clientTypeSelect.value = "osoba";
updateClientTypeFields();
paymentPresetSelect.value = "";
paymentPresetName.value = "";
deletePaymentPresetButton.disabled = true;
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = "";
});
itemsContainer.replaceChildren();
itemIndex = 0;
updateTotal();
};
const loadInvoiceIntoEditor = (invoice) => {
currentInvoiceId = invoice.id;
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
invoiceSaveStatus.textContent = "";
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] || "";
});
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()]);
showInvoiceScreen("list");
};
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));
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 ?? "") const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -13,9 +619,17 @@ const escapeHtml = (value) => String(value ?? "")
const getValue = (id) => { const getValue = (id) => {
const field = document.getElementById(id); const field = document.getElementById(id);
if (!field) {
return "";
}
return ("value" in field ? field.value : field.textContent).trim(); 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 updateTotal = () => {
const total = Array.from(itemsContainer.querySelectorAll(".invoice-item")) const total = Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.reduce((sum, item) => { .reduce((sum, item) => {
@@ -24,154 +638,38 @@ const updateTotal = () => {
return sum + quantity * unitPrice; return sum + quantity * unitPrice;
}, 0); }, 0);
document.getElementById("total").textContent = total.toFixed(2); document.getElementById("total").textContent = `${formatCzechNumber(total)}`;
}; };
const getPersonName = (prefix) => [ printInvoiceButton.addEventListener("click", async () => {
getValue(`${prefix}Jmeno`), try {
getValue(`${prefix}Prijmeni`) const response = await fetch("/api/invoices/pdf", {
].filter(Boolean).join(" "); method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: readInvoiceData(),
invoiceNumber: invoiceNumberDisplay.textContent
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Generování faktury se nepodařilo.");
}
const getAddress = (prefix) => [ const blob = await response.blob();
getValue(`${prefix}UliceCp`), const downloadUrl = URL.createObjectURL(blob);
[getValue(`${prefix}Psc`), getValue(`${prefix}Mesto`)].filter(Boolean).join(" ") const link = document.createElement("a");
].filter(Boolean).join(", "); link.href = downloadUrl;
link.download = `${invoiceNumberDisplay.textContent}.pdf`;
const renderPerson = (prefix) => [ link.click();
getPersonName(prefix), URL.revokeObjectURL(downloadUrl);
getValue(`${prefix}Ico`) ? `IČO: ${getValue(`${prefix}Ico`)}` : "", } catch (error) {
getAddress(prefix) window.alert(error.message);
].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 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++; itemIndex++;
const item = document.createElement("div"); const item = document.createElement("div");
@@ -179,7 +677,7 @@ addItemButton.addEventListener("click", () => {
item.innerHTML = ` item.innerHTML = `
<div class="form-group"> <div class="form-group">
<label for="polozka${itemIndex}Popis">Popis</label> <label for="polozka${itemIndex}Popis">Položka</label>
<input <input
id="polozka${itemIndex}Popis" id="polozka${itemIndex}Popis"
name="polozky[${itemIndex}][popis]" name="polozky[${itemIndex}][popis]"
@@ -250,6 +748,12 @@ addItemButton.addEventListener("click", () => {
</button> </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"); const deleteButton = item.querySelector(".delete-item-button");
deleteButton.addEventListener("click", () => { deleteButton.addEventListener("click", () => {
@@ -259,6 +763,11 @@ addItemButton.addEventListener("click", () => {
itemsContainer.appendChild(item); itemsContainer.appendChild(item);
updateTotal(); updateTotal();
}); };
addItemButton.addEventListener("click", () => addInvoiceItem());
itemsContainer.addEventListener("input", updateTotal); itemsContainer.addEventListener("input", updateTotal);
updateClientTypeFields();
updateSupplierTypeFields();
+636 -4
View File
@@ -22,6 +22,94 @@ body {
font-family: "Trebuchet MS", "Segoe UI", sans-serif; 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 { #main-container {
width: min(100%, 980px); width: min(100%, 980px);
margin: 0 auto; margin: 0 auto;
@@ -31,6 +119,303 @@ body {
box-shadow: var(--shadow); 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 { #main-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -38,8 +423,16 @@ body {
gap: 2rem; gap: 2rem;
} }
#main-form > h2 { .app-heading {
display: flex;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
gap: 1rem;
}
.app-heading h2 {
width: auto;
margin: 0; margin: 0;
color: var(--navy); color: var(--navy);
font-family: Georgia, serif; font-family: Georgia, serif;
@@ -49,6 +442,109 @@ body {
text-align: center; 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 { .form-chapter {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -120,11 +616,16 @@ body {
.third-row { .third-row {
align-items: flex-start; align-items: flex-start;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2rem;
} }
.third-row > .form-part, .total-amount-group {
.third-row > .form-group { grid-column: 1 / -1;
flex: 1; margin: 0;
padding-top: 1rem;
border-top: 1px solid var(--line);
} }
.supplier-customer-section { .supplier-customer-section {
@@ -149,6 +650,91 @@ body {
font-weight: 500; 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 { #itemsContainer {
width: 100%; width: 100%;
} }
@@ -234,6 +820,7 @@ body {
font: inherit; font: inherit;
font-weight: 700; font-weight: 700;
padding: 0.6rem 0.85rem; padding: 0.6rem 0.85rem;
margin-bottom: 0.25rem;
} }
#addItemButton:hover { #addItemButton:hover {
@@ -241,6 +828,10 @@ body {
background: var(--blue); background: var(--blue);
} }
#printInvoiceButton {
margin-top: 0.25rem;
}
@media (max-width: 700px) { @media (max-width: 700px) {
body { body {
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
@@ -250,11 +841,52 @@ body {
padding: 1.5rem 1rem; 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 { .supplier-customer-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;
row-gap: 1rem; 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 { .invoice-item {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+30 -45
View File
@@ -1,48 +1,33 @@
// const request = require("supertest"); const { normalizeInvoiceData } = require('../src/invoice-data');
// const app = require("../src/app");
// describe("GET /search", () => { describe('normalizeInvoiceData', () => {
// afterEach(() => { test('calculates the total from validated line items', () => {
// jest.restoreAllMocks(); 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 () => { expect(invoice.total).toBe(301);
// const response = await request(app).get("/search"); expect(invoice.items[0]).toEqual({
popis: 'Práce',
mnozstvi: 2,
mernaJednotka: 'hod',
cenaZaMj: 125.5
});
});
// expect(response.status).toBe(400); test('rejects negative or non-numeric line item values', () => {
// expect(response.text).toBe("Missing search query"); expect(normalizeInvoiceData({ items: [
// }); { mnozstvi: -1, cenaZaMj: 10 }
] })).toBeNull();
// test("returns filtered search results", async () => { expect(normalizeInvoiceData({ items: [
// global.fetch = jest.fn().mockResolvedValue({ { mnozstvi: 'not-a-number', cenaZaMj: 10 }
// json: jest.fn().mockResolvedValue({ ] })).toBeNull();
// results: [ });
// { });
// url: "https://example.com",
// title: "Example",
// content: "Content",
// irrelevant: "removed"
// }
// ]
// })
// });
// const response = await request(app)
// .get("/search")
// .query({ query: "nodejs" });
// 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"
// }
// ]);
// });
// });