Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb6d9df99 | ||
|
|
cd553bbaea | ||
|
|
2f9a7c587c | ||
|
|
cfcab16149 | ||
|
|
1317c7297f | ||
|
|
3081b2a99b | ||
|
|
3e5a0813b0 | ||
|
|
65c11ba86e | ||
|
|
d440788d0d | ||
|
|
1c5c074fa1 | ||
|
|
d2d3292a73 | ||
|
|
879d8c7df5 | ||
|
|
d73eb10416 | ||
|
|
eb48faa6b6 | ||
|
|
a14500c7ab | ||
|
|
4a2b59f45e | ||
|
|
51e0182399 | ||
|
|
6eb743a1bf | ||
|
|
5882a957ad | ||
|
|
f4d2c30493 | ||
|
|
3848ab47df | ||
|
|
21c23061a4 | ||
|
|
84657b9610 | ||
|
|
2bcf9a2f02 | ||
|
|
82a6bc20f9 |
@@ -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"
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
# 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`.
|
||||||
|
|
||||||
|
## 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
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
Generated
+776
-26
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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",
|
||||||
|
|||||||
+751
@@ -1,7 +1,753 @@
|
|||||||
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 { isValidSignatureDataUrl, normalizeInvoiceData } = require('./invoice-data');
|
||||||
|
|
||||||
const app = express();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/signature', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM signature_profiles WHERE user_id = $1', [req.userId]);
|
||||||
|
res.json({ signature: result.rowCount ? result.rows[0].data_url : null });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/signature', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const dataUrl = req.body.dataUrl;
|
||||||
|
if (!isValidSignatureDataUrl(dataUrl)) {
|
||||||
|
res.status(400).json({ error: 'Podpis musí být platný obrázek JPG nebo PNG.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await pool.query(`
|
||||||
|
INSERT INTO signature_profiles (user_id, data_url)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET data_url = EXCLUDED.data_url
|
||||||
|
RETURNING *
|
||||||
|
`, [req.userId, dataUrl]);
|
||||||
|
res.json({ signature: result.rows[0].data_url });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/signature', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM signature_profiles WHERE user_id = $1', [req.userId]);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceResponse = (row) => ({
|
||||||
|
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)} Kč`]
|
||||||
|
.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)} Kč`, 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
|
// specifying the folder with the static content - html+css
|
||||||
app.use(express.static(__dirname + '/static'));
|
app.use(express.static(__dirname + '/static'));
|
||||||
@@ -10,4 +756,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;
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signature_presets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
data_url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signature_profiles (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data_url TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS 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';
|
||||||
|
|
||||||
|
INSERT INTO signature_profiles (user_id, data_url)
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, data_url
|
||||||
|
FROM signature_presets
|
||||||
|
ORDER BY user_id, created_at DESC
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
`).catch((error) => {
|
||||||
|
schemaPromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { pool, initializeDatabase };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
|
||||||
|
|
||||||
|
const isValidSignatureDataUrl = (value) => typeof value === 'string'
|
||||||
|
&& SIGNATURE_DATA_URL_PATTERN.test(value);
|
||||||
|
|
||||||
|
const normalizeInvoiceData = (data) => {
|
||||||
|
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 = isValidSignatureDataUrl(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 = { isValidSignatureDataUrl, normalizeInvoiceData };
|
||||||
+9
-3
@@ -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);
|
||||||
|
});
|
||||||
+236
-19
@@ -7,10 +7,123 @@
|
|||||||
<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">Spravujte faktury přehledně.</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">
|
<form id="main-form">
|
||||||
<h2>Fakturovač</h2>
|
<div class="invoice-meta">
|
||||||
|
<span id="invoiceNumberDisplay">Nová faktura</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
ico
|
ico
|
||||||
jmeno
|
jmeno
|
||||||
@@ -30,7 +143,7 @@
|
|||||||
----------
|
----------
|
||||||
|
|
||||||
polozky
|
polozky
|
||||||
+ popis
|
+ položka
|
||||||
+ mnozstvi
|
+ mnozstvi
|
||||||
+ merna jednotka MJ
|
+ merna jednotka MJ
|
||||||
+ cena za MJ
|
+ cena za MJ
|
||||||
@@ -43,19 +156,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 +200,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 +262,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 +281,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 +318,49 @@
|
|||||||
</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 Kč</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" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" hidden />
|
||||||
|
<p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p>
|
||||||
|
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="signature-actions">
|
||||||
|
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
|
||||||
|
</div>
|
||||||
|
<div class="saved-data-actions">
|
||||||
|
<button type="button" id="saveSignatureButton">Uložit podpis</button>
|
||||||
|
<button type="button" id="loadSignatureButton" class="secondary-button">Načíst uložený</button>
|
||||||
|
<span id="signatureStatus" class="signature-status" role="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
+836
-144
File diff suppressed because it is too large
Load Diff
+708
-4
@@ -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;
|
||||||
@@ -99,6 +595,12 @@ body {
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted-ink);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group output {
|
.form-group output {
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
@@ -120,11 +622,82 @@ 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,
|
.signature-row {
|
||||||
.third-row > .form-group {
|
align-items: flex-start;
|
||||||
flex: 1;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.supplier-customer-section {
|
||||||
@@ -149,6 +722,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 +892,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 +900,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 +913,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-41
@@ -1,48 +1,58 @@
|
|||||||
// const request = require("supertest");
|
const { isValidSignatureDataUrl, 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();
|
||||||
|
expect(normalizeInvoiceData({ items: [
|
||||||
|
{ mnozstvi: 'not-a-number', cenaZaMj: 10 }
|
||||||
|
] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// test("returns filtered search results", async () => {
|
test('accepts a valid signature data URL and rejects invalid ones', () => {
|
||||||
// global.fetch = jest.fn().mockResolvedValue({
|
const withValidSignature = normalizeInvoiceData({
|
||||||
// json: jest.fn().mockResolvedValue({
|
items: [{ mnozstvi: 1, cenaZaMj: 10 }],
|
||||||
// results: [
|
signature: 'data:image/png;base64,aGVsbG8='
|
||||||
// {
|
});
|
||||||
// url: "https://example.com",
|
expect(withValidSignature.signature).toBe('data:image/png;base64,aGVsbG8=');
|
||||||
// title: "Example",
|
|
||||||
// content: "Content",
|
|
||||||
// irrelevant: "removed"
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// })
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const response = await request(app)
|
const withInvalidSignature = normalizeInvoiceData({
|
||||||
// .get("/search")
|
items: [{ mnozstvi: 1, cenaZaMj: 10 }],
|
||||||
// .query({ query: "nodejs" });
|
signature: 'not-a-data-url'
|
||||||
|
});
|
||||||
|
expect(withInvalidSignature.signature).toBeNull();
|
||||||
|
|
||||||
// expect(response.status).toBe(200);
|
const withoutSignature = normalizeInvoiceData({
|
||||||
// expect(response.headers["content-type"]).toMatch(/application\/json/);
|
items: [{ mnozstvi: 1, cenaZaMj: 10 }]
|
||||||
// expect(response.headers["content-disposition"]).toContain(
|
});
|
||||||
// 'attachment; filename="output.json"'
|
expect(withoutSignature.signature).toBeNull();
|
||||||
// );
|
});
|
||||||
|
|
||||||
// expect(response.body).toEqual([
|
test('validates only supported base64 signature image data URLs', () => {
|
||||||
// {
|
expect(isValidSignatureDataUrl('data:image/jpeg;base64,aGVsbG8=')).toBe(true);
|
||||||
// url: "https://example.com",
|
expect(isValidSignatureDataUrl('data:image/gif;base64,aGVsbG8=')).toBe(false);
|
||||||
// title: "Example",
|
expect(isValidSignatureDataUrl('data:image/png;base64,not valid')).toBe(false);
|
||||||
// content: "Content"
|
});
|
||||||
// }
|
});
|
||||||
// ]);
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|||||||
Reference in New Issue
Block a user