30 Commits

Author SHA1 Message Date
BOHA
d1c5234a03 fix: allow logo endpoint without auth for <img> tag loading
Logo images are loaded via <img src> which doesn't carry auth cookies
reliably during login transitions. Changed from requireAuth to
optionalAuth — logos are not sensitive data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:52:24 +02:00
BOHA
27cc876e82 fix: add missing migration for totp_last_used_counter column
Column existed in Prisma schema but had no migration, causing 500 on
TOTP login in production where the column was absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:45:11 +02:00
BOHA
82919d39f6 fix: remove manual project creation, smart sequence release, received-invoices schema fix
- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint
- Projects can only be created through orders (shared numbering sequence)
- Remove dead CreateProjectSchema and createProject service function
- Delete 'order' row from number_sequences (unused; code uses 'shared')
- Smart sequence release: decrement last_number only when deleting the highest number
- Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns
- Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:36:08 +02:00
BOHA
3481b97d47 fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch
- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:28:15 +02:00
BOHA
d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:40:38 +02:00
BOHA
7f07032bf2 fix: attendance clock-in silently aborted by broken mountedRef guard
mountedRef was initialized to true but never reset on mount. The
cleanup function (useEffect return) set it to false on unmount. In
React 18 Strict Mode, components mount-unmount-remount during dev.
After the first cleanup, mountedRef stayed false forever.

Result: handlePunch set submitting=true, geolocation callbacks fired,
but every callback returned early at `if (!mountedRef.current) return`
before calling submitPunch. No server request, button stuck.

Fix: add `mountedRef.current = true` inside the useEffect body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:34:01 +02:00
BOHA
d873c96ae3 fix: attendance clock-in hanging after geolocation confirmation
On desktop browsers without GPS hardware, getCurrentPosition with
enableHighAccuracy:true can silently hang after the user grants
permission — neither success nor error callback fires.

Previous safety timeout (12s) only reset the button without sending
the punch request, leaving users stuck. Now:
- enableHighAccuracy: false (faster fallback to IP-based location)
- Browser timeout reduced to 5s
- Safety timeout reduced to 6s and automatically calls submitPunch
  without GPS data instead of just showing an error
- Wrapped success callback in try/catch as additional safeguard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:23:12 +02:00
BOHA
c4f6723042 fix: attendance clock-in button stuck when geolocation fails or hangs
handlePunch set submitting=true before calling geolocation, but the
error callback never reset it. When geolocation was denied or timed out:
- Error alert showed
- GPS confirm modal opened
- Button stayed disabled showing "Zpracovávám..."
- User thought it was stuck; no server request appeared to happen

Also added a 12s safety timeout fallback because some browsers silently
hang on getCurrentPosition without calling either callback.

Fix: call setSubmitting(false) in the error callback and clear the
safety timeout in both success and error paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:40:51 +02:00
BOHA
9e699c4dd4 fix: React hooks rules violation in Login.tsx causing crash on load
useCallback hooks were placed AFTER conditional early returns.
When authLoading toggled from true -> false, the hook count changed
between renders (14 hooks vs 17 hooks), triggering React's
"Rendered more hooks than during the previous render" error.

Moved all useCallback definitions before the conditional returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:31:00 +02:00
BOHA
ea81380225 fix: dashboard $queryRaw Date serialization with custom toJSON override
Prisma $queryRaw template literal interpolation fails when Date objects
are passed directly and Date.prototype.toJSON is overridden (returns
local time string instead of UTC ISO). MySQL driver receives a nested
JSON object instead of a flat parameter array.

Fix: convert monthStart/monthEnd to strings via toJSON() before
interpolating into the $queryRaw template literal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:23:27 +02:00
BOHA
a9bc82fac5 fix: Prisma $queryRaw MySQL type coercion for BigInt and Boolean
$queryRaw on MySQL returns BigInt for integer columns and 0/1 for booleans.
Passing these raw values back to Prisma client methods causes validation errors:
- Expected Int, provided BigInt
- Expected Boolean, provided Int

Fixed in auth refresh, TOTP login, and TOTP backup code flows by wrapping
storedToken.id, storedToken.user_id with Number() and remember_me with Boolean().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:18:38 +02:00
BOHA
8c278be941 test: add regression tests for Critical+High FLAWS_REPORT fixes
- Tests caught 2 real bugs:
  - Zod NaN bypass in orders/offers schemas (Number(v) || fallback)
  - invoiceTotalWithVat using Number() on { toNumber() } objects
- 7 new test files covering auth, env, exchange rates, NAS paths,
  schema NaN rejection, invoice VAT calculation, customer validation
- 45 tests passing, build clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:04:20 +02:00
BOHA
aa6c1b5094 refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:45:37 +02:00
BOHA
4f4b12f039 security: fix all Medium findings from FLAWS_REPORT audit
- Auth: TOTP replay protection with counter tracking, constant-time
  backup code comparison, atomic lockout increment, per-token logout
- Invoices/PDFs: net-based VAT calculation, dangerous URL scheme
  stripping in cleanQuillHtml, orders-pdf error handling
- Orders: reject item changes on status transition, cascading
  delete cleanup, take:1 with orderBy
- Projects: atomic rename collision handling, MIME/extension
  validation, empty customer name rejection
- Attendance: Czech public holiday awareness in frontend fund
  calculation, leave_hours 0 handling, invalid date NaN guard,
  bounded per-month queries in workfund
- Users/Admin: profile audit logging + password validation, session
  revocation guard, session ID validation, dashboard DB aggregation,
  soft-deleted record protection in scope templates
- Frontend: FormField label linkage, Pagination ARIA, error
  handling in OrderConfirmationModal, 401 propagation, GPS emoji
  hidden from screen readers, table sort state fix, geolocation
  race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive
  minimal body, CompanySettings ref mutation fix, OfferDetail unlock
  abort, AttendanceBalances combined fetches
- Utils: env validation, Puppeteer concurrency mutex, invoice alert
  cron cleanup on shutdown, body limit alignment, TOTP error logging,
  trustProxy from env, symlink rejection, rate cache Map usage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:24:14 +02:00
BOHA
528e55991b security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 00:58:35 +02:00
BOHA
122eee175e docs: update CLAUDE.md release process and file count
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:36:49 +02:00
BOHA
5a28f75303 1.5.3
- feat: manual VAT override in order confirmation modal
- feat: order confirmation PDF respects user-selected applyVat toggle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:17:20 +02:00
BOHA
07cb428287 1.5.2
- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:23:10 +02:00
BOHA
b197017644 1.5.1 2026-04-02 20:01:44 +02:00
BOHA
e9f07a4a39 fix: invoice edit/list improvements
- Due date uses days selector in edit mode (same as create)
- Overdue invoices fully editable (same as issued)
- Overdue status reversed to issued when due date moved to future
- Invoice list: edit icon for issued/overdue, eye for paid
- Invoice list: PDF opens blob from NAS (removed lang modal)
- NAS cleanup: properly scans directories when cleaning old PDFs
- Fixed syntax error from leftover else block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:01:43 +02:00
BOHA
44d389201c 1.5.0 2026-04-02 15:47:46 +02:00
BOHA
3106aaf314 feat: full invoice editing before payment, NAS cleanup on date change
- Invoice edit mode now uses the same form as create mode (all fields editable)
- Bank account pre-selected by matching IBAN/account number
- Invoice number read-only in edit mode
- Paid invoices remain read-only
- NAS: old PDF deleted when invoice date changes to different month
- Buttons: Zobrazit fakturu, Uložit, Smazat + status transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:47:46 +02:00
BOHA
90e797b8fa 1.4.9 2026-04-02 15:25:35 +02:00
BOHA
1f7362c8af fix: invoice PDF — tighter layout, more room for items
- Page margins reduced, content width 186mm
- Header/grid padding tightened
- Table headers 8.5pt normal case, cells 4px padding
- Footer flows naturally across pages (no forced page break)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:25:35 +02:00
BOHA
fe44a2b12d 1.4.8 2026-04-02 12:55:24 +02:00
BOHA
8a9239311d feat: invoice PDF — larger fonts, order number and date in dates column
- Base font 9pt→10pt, all sub-elements scaled proportionally
- Order number and date shown in dates column when invoice linked to order
- Uses customer_order_number with fallback to internal order_number

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:24 +02:00
BOHA
cd25cd6ee4 1.4.7 2026-04-02 12:31:51 +02:00
BOHA
967fbba2a4 fix: invoice PDF footer — single line with space for signatures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:31:51 +02:00
BOHA
41fe65c7fc 1.4.6 2026-04-02 12:01:52 +02:00
BOHA
09d345a312 fix: invoice PDF table — numbers 8pt, description column wider (36%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:01:51 +02:00
114 changed files with 7587 additions and 3965 deletions

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32 # Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32 JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
ACCESS_TOKEN_EXPIRY=900 ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32 # TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32 TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# File storage # File storage
NAS_PATH=Z:/02_PROJEKTY NAS_PATH=Z:/02_PROJEKTY

303
CLAUDE.md Normal file
View File

@@ -0,0 +1,303 @@
# CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
---
## Tech Stack
| Layer | Technology |
| -------------- | ------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
---
## Project Structure
```
src/
├── server.ts # Fastify server entry point — plugins, routes, error handler
├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
├── schemas/ # Zod validation schemas (one file per entity)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
│ # security.ts (CSP, HSTS, security headers)
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 frontend (57 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
│ ├── pages/ # One file per page/feature
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (auth, numbering)
prisma/
├── schema.prisma # 32 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
```
---
## Commands
```bash
# Development
npm run dev # Starts server in watch mode (manage frontend separately)
npm run dev:server # tsx watch src/server.ts
npm run dev:client # Vite dev server
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass)
npm run test:watch # vitest watch
# Database
npx prisma migrate dev # Apply migrations (dev)
npx prisma migrate deploy # Apply migrations (prod)
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
```
**Do not start the dev server.** The user manages it separately.
---
## Environment Variables
Required:
```
DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>
```
Optional (with defaults):
```
PORT=3001 # Production port (dev default: 3000)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
```
Use `.env` for dev, `.env.test` for tests.
---
## Architecture & Key Patterns
### Request Flow
```
Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth()
→ Zod schema validation (parseBody helper)
→ Route handler
→ Service function
→ Prisma
→ success(reply, data) or error(reply, message, status)
```
### Response Format
All responses use this shape:
```typescript
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error
{ success: false, error: string }
```
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
### Service Pattern
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
### Permissions
```typescript
// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
"preHandler",
requirePermission("invoices.view", "invoices.edit"),
);
// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit Logging
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
---
## Date & Timezone Handling (Critical Gotcha)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
`Date.prototype.toJSON()` to return local time (not UTC). This means:
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
- All API responses with Date fields will contain local time strings.
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
- **Never assume UTC** when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
- The override exists for PHP migration compatibility and Czech date display.
---
## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
---
## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
- Test coverage is minimal: only `auth` and `numbering` are tested.
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
---
## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
---
## Database Conventions
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Known Issues & Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
4. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
8. **Prisma client regeneration** — After any schema change, run `npx prisma generate`. The generated client is not committed to git.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
---
## Release Process
1. Bump version in `package.json`
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
Do not push directly to production or restart services without confirmation.

533
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.4.5", "version": "1.5.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.4.5", "version": "1.5.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -19,6 +19,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -27,6 +28,7 @@
"file-type": "^16.5.4", "file-type": "^16.5.4",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@@ -64,6 +66,53 @@
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -87,6 +136,152 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -630,6 +825,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fastify/accept-negotiator": { "node_modules/@fastify/accept-negotiator": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -1513,6 +1725,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsdom": {
"version": "28.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/tough-cookie": "*",
"parse5": "^7.0.0",
"undici-types": "^7.21.0"
}
},
"node_modules/@types/jsdom/node_modules/undici-types": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -1562,7 +1792,6 @@
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -1639,6 +1868,12 @@
"@types/superagent": "^8.1.0" "@types/superagent": "^8.1.0"
} }
}, },
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2088,6 +2323,15 @@
"bcrypt": "bin/bcrypt" "bcrypt": "bin/bcrypt"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -2494,6 +2738,19 @@
} }
} }
}, },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2510,6 +2767,19 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2546,6 +2816,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -2721,6 +2997,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -3496,6 +3784,18 @@
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3617,6 +3917,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3644,6 +3950,70 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsdom": {
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/jsdom/node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/json-parse-even-better-errors": { "node_modules/json-parse-even-better-errors": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -4142,6 +4512,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/methods": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -4477,6 +4853,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4741,6 +5129,15 @@
"once": "^1.3.1" "once": "^1.3.1"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/puppeteer": { "node_modules/puppeteer": {
"version": "24.40.0", "version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
@@ -5257,6 +5654,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5469,7 +5878,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5635,6 +6043,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tabbable": { "node_modules/tabbable": {
"version": "6.4.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@@ -5768,6 +6182,24 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tldts": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
"integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.28"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
"integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
"license": "MIT"
},
"node_modules/toad-cache": { "node_modules/toad-cache": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
@@ -5803,6 +6235,30 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tree-kill": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5859,11 +6315,19 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
@@ -6027,12 +6491,56 @@
} }
} }
}, },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webdriver-bidi-protocol": { "node_modules/webdriver-bidi-protocol": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which-module": { "node_modules/which-module": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -6100,6 +6608,21 @@
} }
} }
}, },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.4.5", "version": "1.5.5",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -34,6 +34,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -42,6 +43,7 @@
"file-type": "^16.5.4", "file-type": "^16.5.4",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

@@ -0,0 +1,2 @@
-- Add unique constraint on number_sequences(type, year) for atomic numbering
ALTER TABLE number_sequences ADD UNIQUE INDEX idx_number_sequences_type_year (`type`, `year`);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `totp_last_used_counter` INTEGER NULL;

View File

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date") @@unique([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
} }
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid") @@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
@@ -104,7 +105,7 @@ model company_settings {
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36) uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
@@ -165,7 +166,7 @@ model invoice_items {
model invoices { model invoices {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50) invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
@@ -196,6 +197,7 @@ model invoices {
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date") @@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue") @@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id") @@index([order_id], map: "order_id")
} }
@@ -253,6 +255,8 @@ model number_sequences {
type String? @db.VarChar(50) type String? @db.VarChar(50)
year Int? year Int?
last_number Int? @default(0) last_number Int? @default(0)
@@unique([type, year], map: "idx_number_sequences_type_year")
} }
model order_items { model order_items {
@@ -286,7 +290,7 @@ model order_sections {
model orders { model orders {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
order_number String? @db.VarChar(50) order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
customer_order_number String? @db.VarChar(100) customer_order_number String? @db.VarChar(100)
attachment_data Bytes? attachment_data Bytes?
attachment_name String? @db.VarChar(255) attachment_name String? @db.VarChar(255)
@@ -338,7 +342,7 @@ model project_notes {
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
@@ -350,6 +354,7 @@ model projects {
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
@@ -383,7 +388,7 @@ model quotation_items {
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50) project_code String? @db.VarChar(50)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
@@ -432,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100) file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt file_size Int? @db.UnsignedInt
notes String? @db.Text notes String? @db.Text
uploaded_by Int? @db.UnsignedInt uploaded_by Int?
created_at DateTime @default(now()) @db.DateTime(0) created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0) modified_at DateTime @default(now()) @db.DateTime(0)
@@ -444,7 +449,7 @@ model received_invoices {
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model refresh_tokens { model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt id Int @id @default(autoincrement()) @db.UnsignedInt
user_id Int @db.UnsignedInt user_id Int
token_hash String @unique(map: "token_hash") @db.VarChar(64) token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0) expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0) replaced_at DateTime? @db.DateTime(0)
@@ -578,6 +583,7 @@ model users {
totp_secret String? @db.VarChar(255) totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false) totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[] attendance attendance[]
leave_balances leave_balances[] leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
@@ -624,6 +630,7 @@ model invoice_alert_log {
invoice_id Int invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due" alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0) sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type]) @@unique([invoice_type, invoice_id, alert_type])
} }

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { verifyAccessToken, hashToken } from "../services/auth";
import jwt from "jsonwebtoken";
import { config } from "../config/env";
describe("auth service", () => {
describe("verifyAccessToken", () => {
it("returns null and logs error for invalid JWT", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await verifyAccessToken("invalid-token");
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.calls[0][0]).toMatch(/JWT verification error/);
consoleSpy.mockRestore();
});
it("returns null for expired JWT", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const expiredToken = jwt.sign(
{ sub: 1, username: "test", role: "user" },
config.jwt.secret,
{ expiresIn: -1 },
);
const result = await verifyAccessToken(expiredToken);
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("hashToken", () => {
it("produces deterministic SHA-256 hex output", () => {
const t1 = hashToken("hello");
const t2 = hashToken("hello");
expect(t1).toBe(t2);
expect(t1).toMatch(/^[a-f0-9]{64}$/);
});
it("produces different hashes for different inputs", () => {
const t1 = hashToken("a");
const t2 = hashToken("b");
expect(t1).not.toBe(t2);
});
});
});

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { UpdateCustomerSchema } from "../schemas/customers.schema";
describe("UpdateCustomerSchema", () => {
it("rejects empty name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("accepts valid name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
expect(result.success).toBe(true);
});
it("accepts partial updates without name", () => {
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(true);
});
});

34
src/__tests__/env.test.ts Normal file
View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { config } from "../config/env";
describe("env validation", () => {
it("has numeric port within valid range", () => {
expect(typeof config.port).toBe("number");
expect(config.port).toBeGreaterThan(0);
expect(config.port).toBeLessThanOrEqual(65535);
});
it("has JWT_SECRET defined", () => {
expect(config.jwt.secret).toBeTruthy();
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
});
it("has TOTP_ENCRYPTION_KEY defined", () => {
expect(config.totp.encryptionKey).toBeTruthy();
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
});
it("has positive JWT expiry values", () => {
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
});
it("has positive maxUploadSize", () => {
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
});
it("has DATABASE_URL defined", () => {
expect(config.db.url).toBeTruthy();
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { toCzk, getRate } from "../services/exchange-rates";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("exchange-rates", () => {
beforeEach(() => {
mockFetch.mockReset();
});
describe("toCzk", () => {
it("returns amount unchanged for CZK", async () => {
const result = await toCzk(123.45, "CZK");
expect(result).toBe(123.45);
});
it("throws for unknown currency when API fails and no cache", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(toCzk(100, "XYZ")).rejects.toThrow(
/Nepodařilo se získat aktuální kurzy/,
);
});
it("throws for unknown currency even when API succeeds", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("converts EUR using fetched rate", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
const result = await toCzk(100, "EUR");
expect(result).toBe(2500);
});
});
describe("getRate", () => {
it("returns 1 for CZK", async () => {
const result = await getRate("CZK");
expect(result).toBe(1);
});
it("throws for unknown currency", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { invoiceTotalWithVat } from "../services/invoices.service";
describe("invoiceTotalWithVat", () => {
it("calculates subtotal without VAT when apply_vat is false", () => {
const inv = {
apply_vat: false,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 2 },
unit_price: { toNumber: () => 100 },
vat_rate: { toNumber: () => 21 },
},
],
};
expect(invoiceTotalWithVat(inv)).toBe(200);
});
it("rounds each line VAT to 2 decimals before accumulation", () => {
// 3 items @ 33.33 with 21% VAT
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
// Total VAT = 7.00 * 3 = 21.00
// Subtotal = 33.33 * 3 = 99.99
// Total = 99.99 + 21.00 = 120.99
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: Array.from({ length: 3 }, () => ({
quantity: { toNumber: () => 1 },
unit_price: { toNumber: () => 33.33 },
vat_rate: { toNumber: () => 21 },
})),
};
expect(invoiceTotalWithVat(inv)).toBe(120.99);
});
it("handles null quantity and unit_price gracefully", () => {
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 0 },
unit_price: { toNumber: () => 0 },
vat_rate: { toNumber: () => 21 },
},
],
};
expect(invoiceTotalWithVat(inv)).toBe(0);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { NasFileManager } from "../services/nas-file-manager";
describe("NasFileManager path traversal", () => {
const nas = new NasFileManager();
describe("deleteItem", () => {
it("rejects empty path", async () => {
const result = await nas.deleteItem("PRJ-001", "");
expect(result).toContain("kořenovou složku");
});
it("rejects root path /", async () => {
const result = await nas.deleteItem("PRJ-001", "/");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.deleteItem("PRJ-001", ".");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory ./", async () => {
const result = await nas.deleteItem("PRJ-001", "./");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal ..", async () => {
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
expect(result).toContain("Neplatná cesta");
});
});
describe("moveItem", () => {
it("rejects empty fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects root fromPath /", async () => {
const result = await nas.moveItem("PRJ-001", "/", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.moveItem("PRJ-001", ".", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal in fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
expect(result).toContain("Neplatná cesta");
});
});
});

View File

@@ -1,21 +1,50 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { import {
generateSharedNumber, generateSharedNumber,
generateOfferNumber, generateOfferNumber,
} from "../services/numbering.service"; } from "../services/numbering.service";
import prisma from "../config/database";
describe("generateSharedNumber", () => { describe("generateSharedNumber", () => {
it("returns correct format (YYtypeCode + 4 digits)", async () => { beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
it("returns a non-empty string", async () => {
const num = await generateSharedNumber(); const num = await generateSharedNumber();
const yy = String(new Date().getFullYear()).slice(-2); expect(typeof num).toBe("string");
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`)); expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
const num1 = await generateSharedNumber();
const num2 = await generateSharedNumber();
expect(num1).not.toBe(num2);
}); });
}); });
describe("generateOfferNumber", () => { describe("generateOfferNumber", () => {
it("returns correct format (YEAR/PREFIX/NNN)", async () => { beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
it("returns a non-empty string", async () => {
const num = await generateOfferNumber(); const num = await generateOfferNumber();
const year = new Date().getFullYear(); expect(typeof num).toBe("string");
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`)); expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
const num1 = await generateOfferNumber();
const num2 = await generateOfferNumber();
expect(num1).not.toBe(num2);
}); });
}); });

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { CreateOrderSchema } from "../schemas/orders.schema";
import { CreateQuotationSchema } from "../schemas/offers.schema";
describe("Zod NaN rejection", () => {
describe("CreateOrderSchema", () => {
it("rejects NaN string in quantity", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: "not-a-number" }],
});
expect(result.success).toBe(false);
});
it("rejects NaN string in unit_price", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ unit_price: "abc" }],
});
expect(result.success).toBe(false);
});
it("accepts valid numbers", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: 2, unit_price: 100 }],
});
expect(result.success).toBe(true);
});
});
describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
items: [{ quantity: "bad" }],
});
expect(result.success).toBe(false);
});
});
});

View File

@@ -46,7 +46,6 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders")); const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects")); const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices")); const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
@@ -104,7 +103,6 @@ export default function AdminApp() {
<Route path="orders" element={<Orders />} /> <Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} /> <Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} /> <Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} /> <Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} /> <Route path="invoices/new" element={<InvoiceDetail />} />

View File

@@ -75,6 +75,19 @@ function NativeInput({
disabled, disabled,
}: NativeInputProps) { }: NativeInputProps) {
const type = modeToInputType[mode] || "date"; const type = modeToInputType[mode] || "date";
// For time inputs, min/max must be in HH:mm format, not date format
const formatTimeMinMax = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
// otherwise it's not a valid time min/max — return undefined
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
if (val.includes(":")) return val.substring(0, 5);
return undefined;
};
const minProp =
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
const maxProp =
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
return ( return (
<input <input
type={type} type={type}
@@ -84,14 +97,14 @@ function NativeInput({
className="admin-form-input" className="admin-form-input"
required={required} required={required}
disabled={disabled} disabled={disabled}
min={minDate || undefined} min={minProp}
max={maxDate || undefined} max={maxProp}
/> />
); );
} }
interface AdminDatePickerProps { interface AdminDatePickerProps {
mode?: "date" | "month" | "datetime" | "time"; mode?: "date" | "month" | "time";
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
minDate?: string; minDate?: string;
@@ -165,17 +178,22 @@ export default function AdminDatePicker({
return undefined; return undefined;
}; };
const commonProps = { const customInput = useMemo(
selected: toDate(value), () => (
onChange: handleChange,
locale: "cs",
customInput: (
<CustomInput <CustomInput
required={required} required={required}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
/> />
), ),
[required, placeholder, disabled],
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
minDate: parseMinMax(minDate), minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate), maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const, popperPlacement: "bottom-start" as const,

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
formatDate, formatDate,
formatDatetime, formatDatetime,
@@ -64,21 +64,30 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
let h: number, let h: number,
m: number, m: number,
isActive = false; isActive = false;
let durationValid = true;
if (log.hours !== null && log.hours !== undefined) { if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0; m = parseInt(String(log.minutes)) || 0;
} else { } else {
isActive = !log.ended_at; isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date(); const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor( const start = log.started_at ? new Date(log.started_at) : null;
(end.getTime() - new Date(log.started_at!).getTime()) / 60000, if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
const mins = Math.max(
0,
Math.floor((end.getTime() - start.getTime()) / 60000),
); );
h = Math.floor(mins / 60); h = Math.floor(mins / 60);
m = mins % 60; m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
} }
return ( return (
<span <span
key={log.id || i} key={log.id ?? i}
className="admin-badge" className="admin-badge"
style={{ style={{
fontSize: "0.7rem", fontSize: "0.7rem",
@@ -86,8 +95,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined, background: isActive ? "var(--accent-light)" : undefined,
}} }}
> >
{log.project_name || `#${log.project_id}`} ({h}: {log.project_name || `#${log.project_id}`}{" "}
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""}) {durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})`
: "—"}
</span> </span>
); );
})} })}
@@ -143,7 +154,8 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work"; const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work"; const isLeave = leaveType !== "work";
const workMinutes = isLeave const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60 ? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes(record); : calculateWorkMinutes(record);
const hasLocation = const hasLocation =
(record.arrival_lat && record.arrival_lng) || (record.arrival_lat && record.arrival_lng) ||
@@ -183,7 +195,7 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu" title="Zobrazit polohu"
aria-label="Zobrazit polohu" aria-label="Zobrazit polohu"
> >
{"\uD83D\uDCCD"} <span aria-hidden="true">{"\uD83D\uDCCD"}</span>
</Link> </Link>
) : ( ) : (
"\u2014" "\u2014"

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
} }
interface BulkAttendanceModalProps { interface BulkAttendanceModalProps {
show: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
form: BulkAttendanceForm; form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void; setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
} }
export default function BulkAttendanceModal({ export default function BulkAttendanceModal({
show, isOpen,
onClose, onClose,
form, form,
setForm, setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser, toggleUser,
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
useModalLock(show); useModalLock(isOpen);
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {isOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
/> />
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2> <h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",

View File

@@ -14,6 +14,71 @@ interface ConfirmModalProps {
loading?: boolean; loading?: boolean;
} }
function ConfirmIcon({ type }: { type: string }) {
switch (type) {
case "danger":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
case "info":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
case "warning":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
default:
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
}
export default function ConfirmModal({ export default function ConfirmModal({
isOpen, isOpen,
onClose, onClose,
@@ -39,6 +104,9 @@ export default function ConfirmModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -46,20 +114,11 @@ export default function ConfirmModal({
> >
<div className="admin-modal-body admin-confirm-content"> <div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}> <div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg <ConfirmIcon type={type} />
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div> </div>
<h2 className="admin-confirm-title">{title}</h2> <h2 id="confirm-modal-title" className="admin-confirm-title">
{title}
</h2>
<p className="admin-confirm-message">{message}</p> <p className="admin-confirm-message">{message}</p>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">

View File

@@ -1,4 +1,10 @@
import type { CSSProperties, ReactNode } from "react"; import {
type CSSProperties,
type ReactNode,
isValidElement,
cloneElement,
useId,
} from "react";
interface FormFieldProps { interface FormFieldProps {
label: ReactNode; label: ReactNode;
@@ -15,13 +21,22 @@ export default function FormField({
required, required,
style, style,
}: FormFieldProps) { }: FormFieldProps) {
const generatedId = useId();
const childProps = isValidElement(children)
? (children.props as Record<string, unknown>)
: null;
const childId = childProps?.id ? String(childProps.id) : generatedId;
const childWithId = isValidElement(children)
? cloneElement(children, { id: childId } as React.Attributes)
: children;
return ( return (
<div className="admin-form-group" style={style}> <div className="admin-form-group" style={style}>
<label className="admin-form-label"> <label className="admin-form-label" htmlFor={childId}>
{label} {label}
{required && <span className="admin-form-required"> *</span>} {required && <span className="admin-form-required"> *</span>}
</label> </label>
{children} {childWithId}
{error && <span className="admin-form-error">{error}</span>} {error && <span className="admin-form-error">{error}</span>}
</div> </div>
); );

View File

@@ -0,0 +1,396 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (
lang: string,
applyVat: boolean,
items?: ConfirmationItem[],
) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
isOpen,
onClose,
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const handleEditGenerate = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, items);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const updateItem = useCallback(
(
index: number,
field: keyof ConfirmationItem,
value: string | number | boolean,
) => {
setItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
},
[],
);
const removeItem = useCallback((index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
}, []);
const addItem = useCallback(() => {
setItems((prev) => [
...prev,
{
description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
vat_rate: defaultVatRate,
},
]);
}, [defaultVatRate]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Jazyk dokumentu</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setLang("cs")}
className={
lang === "cs"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Čeština
</button>
<button
type="button"
onClick={() => setLang("en")}
className={
lang === "en"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
English
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setApplyVatState(true)}
className={
applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
S DPH
</button>
<button
type="button"
onClick={() => setApplyVatState(false)}
className={
!applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Bez DPH
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Použít položky z objednávky"
)}
</button>
<button
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
className="admin-btn admin-btn-secondary w-full"
>
Upravit položky
</button>
</div>
</div>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
className="admin-form-input"
style={{ width: "60px" }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
/>
</td>
<td>
<input
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
/>
</td>
<td>
<button
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
</div>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<button
type="button"
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
)}
</button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -36,13 +36,18 @@ export default function Pagination({
}; };
return ( return (
<div className="admin-pagination"> <div
className="admin-pagination"
role="navigation"
aria-label="Stránkování"
>
<div className="admin-pagination-info">{total} záznamů</div> <div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls"> <div className="admin-pagination-controls">
<button <button
disabled={page <= 1} disabled={page <= 1}
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
className="admin-pagination-page" className="admin-pagination-page"
aria-label="Předchozí stránka"
> >
<svg <svg
width="16" width="16"
@@ -65,6 +70,8 @@ export default function Pagination({
key={p} key={p}
onClick={() => onPageChange(p)} onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? "active" : ""}`} className={`admin-pagination-page ${p === page ? "active" : ""}`}
aria-label={`Stránka ${p}`}
aria-current={p === page ? "page" : undefined}
> >
{p} {p}
</button> </button>
@@ -74,6 +81,7 @@ export default function Pagination({
disabled={page >= total_pages} disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
className="admin-pagination-page" className="admin-pagination-page"
aria-label="Další stránka"
> >
<svg <svg
width="16" width="16"

View File

@@ -197,6 +197,7 @@ export default function ProjectFileManager({
}: ProjectFileManagerProps) { }: ProjectFileManagerProps) {
const alert = useAlert(); const alert = useAlert();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]); const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -768,10 +769,26 @@ export default function ProjectFileManager({
}} }}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleRename(item); if (e.key === "Enter") {
if (e.key === "Escape") setRenamingItem(null); e.preventDefault();
handleRename(item);
}
if (e.key === "Escape") {
e.preventDefault();
isCancelling.current = true;
setRenamingItem(null);
setRenameValue(item.name);
setTimeout(() => {
isCancelling.current = false;
}, 0);
}
}}
onBlur={() => {
if (isCancelling.current) {
return;
}
handleRename(item);
}} }}
onBlur={() => handleRename(item)}
/> />
) : ( ) : (
<FileNameCell <FileNameCell

View File

@@ -1,66 +1,7 @@
import { useMemo, useRef, useCallback } from "react"; import { useMemo, useRef, useCallback, useEffect } from "react";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
const Quill = ReactQuill.Quill;
if (!(Quill as any).__bohaRegistered) {
const Font = Quill.import("attributors/class/font") as any;
Font.whitelist = [
"arial",
"tahoma",
"verdana",
"georgia",
"times-new-roman",
"courier-new",
"trebuchet-ms",
"impact",
"comic-sans-ms",
"lucida-console",
"palatino-linotype",
"garamond",
];
Quill.register(Font, true);
const SizeStyle = Quill.import("attributors/style/size") as any;
SizeStyle.whitelist = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
Quill.register(SizeStyle, true);
(Quill as any).__bohaRegistered = true;
}
const Font = Quill.import("attributors/class/font") as any;
const SIZE_WHITELIST = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
const COLORS = [ const COLORS = [
"#000000", "#000000",
"#1a1a1a", "#1a1a1a",
@@ -95,8 +36,6 @@ const COLORS = [
]; ];
const TOOLBAR = [ const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
["bold", "italic", "underline", "strike"], ["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }], [{ color: COLORS }, { background: COLORS }],
[{ list: "ordered" }, { list: "bullet" }], [{ list: "ordered" }, { list: "bullet" }],
@@ -107,8 +46,6 @@ const TOOLBAR = [
]; ];
const FORMATS = [ const FORMATS = [
"font",
"size",
"bold", "bold",
"italic", "italic",
"underline", "underline",
@@ -159,6 +96,13 @@ export default function RichEditor({
[onChange], [onChange],
); );
useEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
}, []);
return ( return (
<div <div
className="admin-rich-editor" className="admin-rich-editor"

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps { export interface ShiftFormModalProps {
mode: "create" | "edit"; mode: "create" | "edit";
show: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSubmit: () => void; onSubmit: () => void;
form: ShiftFormData; form: ShiftFormData;
@@ -196,7 +196,7 @@ function ProjectLogRow({
export default function ShiftFormModal({ export default function ShiftFormModal({
mode, mode,
show, isOpen,
onClose, onClose,
onSubmit, onSubmit,
form, form,
@@ -208,7 +208,7 @@ export default function ShiftFormModal({
onShiftDateChange, onShiftDateChange,
editingRecord, editingRecord,
}: ShiftFormModalProps) { }: ShiftFormModalProps) {
useModalLock(show); useModalLock(isOpen);
const isCreate = mode === "create"; const isCreate = mode === "create";
const isWorkType = form.leave_type === "work"; const isWorkType = form.leave_type === "work";
@@ -240,7 +240,7 @@ export default function ShiftFormModal({
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {isOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -251,13 +251,16 @@ export default function ShiftFormModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"} {isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2> </h2>
{!isCreate && editingRecord && ( {!isCreate && editingRecord && (

View File

@@ -5,6 +5,7 @@ import {
useCallback, useCallback,
useMemo, useMemo,
useRef, useRef,
useEffect,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const counterRef = useRef(0); const counterRef = useRef(0);
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => {
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current.clear();
};
}, []);
const addAlert = useCallback( const addAlert = useCallback(
(message: string, type = "success", duration = 4000) => { (message: string, type = "success", duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`; const id = `${Date.now()}-${counterRef.current++}`;
@@ -47,7 +57,11 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{ id, message, type: type as Alert["type"] }, { id, message, type: type as Alert["type"] },
]); ]);
if (duration > 0) { if (duration > 0) {
setTimeout(() => removeAlert(id), duration); const timeoutId = setTimeout(() => {
timeoutsRef.current.delete(timeoutId);
removeAlert(id);
}, duration);
timeoutsRef.current.add(timeoutId);
} }
return id; return id;
}, },

View File

@@ -84,32 +84,31 @@ function mapUser(u: Record<string, unknown> | null): User | null {
} as User; } as User;
} }
let accessToken: string | null = null;
let tokenExpiresAt: number | null = null;
let cachedUser: User | null = null;
let sessionFetched = false;
let silentRefreshInFlight: Promise<boolean> | null = null;
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(cachedUser); const accessTokenRef = useRef<string | null>(null);
const [loading, setLoading] = useState(!sessionFetched); const tokenExpiresAtRef = useRef<number | null>(null);
const cachedUserRef = useRef<User | null>(null);
const sessionFetchedRef = useRef(false);
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
const [user, setUser] = useState<User | null>(cachedUserRef.current);
const [loading, setLoading] = useState(!sessionFetchedRef.current);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
cachedUser = user;
}, [user]);
const getAccessTokenFn = useCallback((): string | null => { const getAccessTokenFn = useCallback((): string | null => {
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null; if (
return accessToken; !tokenExpiresAtRef.current ||
Date.now() > tokenExpiresAtRef.current - 30000
)
return null;
return accessTokenRef.current;
}, []); }, []);
const setAccessTokenFn = useCallback( const setAccessTokenFn = useCallback(
(token: string | null, expiresIn?: number) => { (token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900; // default 15 min matching backend config const ttl = expiresIn ?? 900; // default 15 min matching backend config
accessToken = token; accessTokenRef.current = token;
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null; tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;
@@ -126,7 +125,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const silentRefresh = useCallback(async (): Promise<boolean> => { const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed // Deduplicate concurrent refresh calls — token rotation means only one call can succeed
if (silentRefreshInFlight) return silentRefreshInFlight; if (silentRefreshInFlightRef.current)
return silentRefreshInFlightRef.current;
const promise = (async (): Promise<boolean> => { const promise = (async (): Promise<boolean> => {
try { try {
@@ -140,21 +140,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
return true; return true;
} }
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
setSessionExpired(); setSessionExpired();
return false; return false;
} catch { } catch {
// Network error — don't kick the user out, just return false // Network error — don't kick the user out, just return false
return false; return false;
} finally { } finally {
silentRefreshInFlight = null; silentRefreshInFlightRef.current = null;
} }
})(); })();
silentRefreshInFlight = promise; silentRefreshInFlightRef.current = promise;
return promise; return promise;
}, [setAccessTokenFn]); }, [setAccessTokenFn]);
@@ -172,12 +172,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
headers, headers,
}); });
if (response.status === 429 || response.status >= 500) if (response.status === 429 || response.status >= 500)
return !!cachedUser; return !!cachedUserRef.current;
const data = await response.json(); const data = await response.json();
if (data.success && data.data?.user) { if (data.success && data.data?.user) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token); if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
return true; return true;
} }
} }
@@ -185,15 +185,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refreshed = await silentRefresh(); const refreshed = await silentRefresh();
if (refreshed) return true; if (refreshed) return true;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
return false; return false;
} catch { } catch {
return !!cachedUser; return !!cachedUserRef.current;
} finally { } finally {
setLoading(false); setLoading(false);
sessionFetched = true; sessionFetchedRef.current = true;
} }
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]); }, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
@@ -231,8 +231,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -264,14 +264,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login_token: loginToken, login_token: loginToken,
totp_code: code, totp_code: code,
remember_me: remember, remember_me: remember,
isBackup,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -296,11 +297,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
sessionFetched = false; sessionFetchedRef.current = false;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;

View File

@@ -23,8 +23,9 @@ export default function useApiCall() {
abortRef.current = controller; abortRef.current = controller;
try { try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, { const response = await apiFetch(url, {
...options, ...restOptions,
signal: controller.signal, signal: controller.signal,
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -224,11 +224,20 @@ function computeUserTotals(
// Print helpers // Print helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string { function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0) if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`; return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
if (userData.missing > 0) if (userData.missing > 0)
return `<span style="color:#dc2626">${userData.missing}h</span>`; return `<span style="color:#dc2626">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>'; return '<span style="color:#16a34a">splněno</span>';
} }
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 0; h = 0;
m = 0; m = 0;
} }
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`; return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
}) })
.join(""); .join("");
} }
return record.project_name || "—"; return escapeHtml(record.project_name || "—");
} }
function buildLeaveSummaryHtml( function buildLeaveSummaryHtml(
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
printData: Record<string, any>, printData: Record<string, any>,
): string { ): string {
const bal = printData.leave_balances[userId]; const bal = printData.leave_balances[userId];
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`; let parts = `<strong>Dovolená ${escapeHtml(String(printData.year))}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
if (userData.vacation_hours > 0) if (userData.vacation_hours > 0)
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`; parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0) if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`; parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
if (userData.holiday_hours > 0) if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`; parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0) if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`; parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`; return `<div class="leave-summary">${parts}</div>`;
} }
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
const breakCell = const breakCell =
isLeave || !record.break_start || !record.break_end isLeave || !record.break_start || !record.break_end
? "—" ? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`; : `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
return `<tr> return `<tr>
<td>${formatDate(record.shift_date)}</td> <td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td> <td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td> <td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
<td class="text-center">${breakCell}</td> <td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td> <td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td> <td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td> <td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ""}</td> <td>${escapeHtml(record.notes || "")}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
userData.fund !== null userData.fund !== null
? `<tr> ? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td> <td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${userData.covered}h / ${userData.fund}h</td> <td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
<td colspan="2">${renderFundStatus(userData)}</td> <td colspan="2">${renderFundStatus(userData)}</td>
</tr>` </tr>`
: ""; : "";
return `<div class="user-section"> return `<div class="user-section">
<div class="user-header"> <div class="user-header">
<h3>${userData.name}</h3> <h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span> <span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div> </div>
${leaveHtml} ${leaveHtml}
<table> <table>
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
<tfoot> <tfoot>
<tr> <tr>
<td colspan="6" class="text-right">Odpracováno:</td> <td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${formatMinutes(userData.minutes)} h</td> <td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td> <td colspan="2"></td>
</tr> </tr>
${fundRow} ${fundRow}
@@ -365,7 +374,7 @@ function buildPrintHtml(
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${pData.month_name}</title> <title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -428,11 +437,11 @@ function buildPrintHtml(
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" /> <img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text"> <div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1> <h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${companyName}</div> <div class="company">${escapeHtml(companyName)}</div>
</div> </div>
</div> </div>
<div class="print-header-right"> <div class="print-header-right">
<div class="period">${pData.month_name}</div> <div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote} ${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div> <div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div> </div>
@@ -1037,7 +1046,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>' ? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: ""; : "";
const filterNote = pData.selected_user_name const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>` ? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
: ""; : "";
const bodyContent = buildPrintHtml( const bodyContent = buildPrintHtml(
pData, pData,
@@ -1051,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open(); printWindow.document.open();
printWindow.document.write(bodyContent); printWindow.document.write(bodyContent);
printWindow.document.close(); printWindow.document.close();
printWindow.onload = () => printWindow.print(); printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
} }
} }
} catch { } catch {

View File

@@ -43,8 +43,14 @@ export default function useListData<T = unknown>(
const [initialLoad, setInitialLoad] = useState(true); const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null); const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const extraParamsKey = Object.entries(extraParams)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
const controller = new AbortController(); const controller = new AbortController();
@@ -66,7 +72,10 @@ export default function useListData<T = unknown>(
? `${endpoint}?${params}` ? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`; : `${API_BASE}/${endpoint}?${params}`;
const response = await apiFetch(url, { signal: controller.signal }); const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) return; if (response.status === 401) {
window.location.href = "/login";
return;
}
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const data = dataKey const data = dataKey
@@ -92,8 +101,10 @@ export default function useListData<T = unknown>(
} }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return; if (err instanceof Error && err.name === "AbortError") return;
if (!mountedRef.current) return;
alert.error(errorMsg); alert.error(errorMsg);
} finally { } finally {
if (!mountedRef.current) return;
setLoading(false); setLoading(false);
setInitialLoad(false); setInitialLoad(false);
} }
@@ -105,12 +116,14 @@ export default function useListData<T = unknown>(
page, page,
perPage, perPage,
dataKey, dataKey,
JSON.stringify(extraParams), extraParamsKey,
]); // eslint-disable-line react-hooks/exhaustive-deps ]);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
fetchData(); fetchData();
return () => { return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
}; };
}, [fetchData]); }, [fetchData]);

View File

@@ -1,14 +1,16 @@
import { useEffect } from "react"; import { useEffect } from "react";
let activeLocks = 0;
export default function useModalLock(isOpen: boolean): void { export default function useModalLock(isOpen: boolean): void {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = "hidden"; if (activeLocks === 0) document.body.style.overflow = "hidden";
} else { activeLocks++;
document.body.style.overflow = "";
}
return () => { return () => {
document.body.style.overflow = ""; activeLocks = Math.max(0, activeLocks - 1);
if (activeLocks === 0) document.body.style.overflow = "";
}; };
}
}, [isOpen]); }, [isOpen]);
} }

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback } from "react";
interface SortState { interface SortState {
sort: string; sort: string;
@@ -13,10 +13,10 @@ export default function useTableSort(
sort: defaultSort, sort: defaultSort,
order: defaultOrder, order: defaultOrder,
}); });
const userClicked = useRef(false); const [userClicked, setUserClicked] = useState(false);
const handleSort = useCallback((column: string) => { const handleSort = useCallback((column: string) => {
userClicked.current = true; setUserClicked(true);
setState((prev) => { setState((prev) => {
if (prev.sort === column) { if (prev.sort === column) {
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" }; return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
@@ -25,7 +25,7 @@ export default function useTableSort(
}); });
}, []); }, []);
const activeSort = userClicked.current ? state.sort : null; const activeSort = userClicked ? state.sort : null;
return { sort: state.sort, order: state.order, handleSort, activeSort }; return { sort: state.sort, order: state.order, handleSort, activeSort };
} }

View File

@@ -381,7 +381,8 @@
.admin-rich-editor .ql-container.ql-snow { .admin-rich-editor .ql-container.ql-snow {
border: none; border: none;
border-radius: 0 0 0.5rem 0.5rem; border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem; font-family: Tahoma, sans-serif;
font-size: 14px;
} }
.admin-rich-editor .ql-editor { .admin-rich-editor .ql-editor {
@@ -389,7 +390,8 @@
padding: 0.75rem; padding: 0.75rem;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
font-size: 0.875rem; font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg); background: var(--input-bg);
} }

View File

@@ -108,7 +108,7 @@ export default function Attendance() {
project_logs: [], project_logs: [],
active_project_id: null, active_project_id: null,
}); });
const [showLeaveModal, setShowLeaveModal] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({ const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation", leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0], date_from: new Date().toISOString().split("T")[0],
@@ -122,14 +122,20 @@ export default function Attendance() {
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]); const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null); const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{ const [gpsConfirm, setGpsConfirm] = useState<{
show: boolean; isOpen: boolean;
action: string | null; action: string | null;
}>({ show: false, action: null }); }>({ isOpen: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null); const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
return () => { return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort(); if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
}; };
}, []); }, []);
@@ -173,14 +179,25 @@ export default function Attendance() {
loadProjects(); loadProjects();
}, []); }, []);
useModalLock(showLeaveModal); useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />; if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => { const handlePunch = (action: string) => {
setSubmitting(true); setSubmitting(true);
latestActionRef.current = action;
// Some browsers silently hang on getCurrentPosition (especially with
// enableHighAccuracy:true on desktops without GPS). Use a short safety
// timeout and proceed without GPS rather than leaving the user stuck.
const safetyTimeout = setTimeout(() => {
if (mountedRef.current) {
submitPunch(action, {});
}
}, 6000);
if (!navigator.geolocation) { if (!navigator.geolocation) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná"); alert.warning("GPS není dostupná");
submitPunch(action, {}); submitPunch(action, {});
return; return;
@@ -188,14 +205,21 @@ export default function Attendance() {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
try {
const { latitude, longitude, accuracy } = position.coords; const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" }); submitPunch(action, { latitude, longitude, accuracy, address: "" });
} catch {
submitPunch(action, {});
}
// Fire-and-forget reverse geocoding to update the address later
if (geoAbortRef.current) geoAbortRef.current.abort(); if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController(); const controller = new AbortController();
geoAbortRef.current = controller; geoAbortRef.current = controller;
fetch( fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, `https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
{ {
headers: { "Accept-Language": "cs" }, headers: { "Accept-Language": "cs" },
signal: controller.signal, signal: controller.signal,
@@ -203,13 +227,15 @@ export default function Attendance() {
) )
.then((r) => r.json()) .then((r) => r.json())
.then((geoData) => { .then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) { if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, { apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
latitude, latitude: position.coords.latitude,
longitude, longitude: position.coords.longitude,
address: geoData.display_name, address: geoData.display_name,
punch_action: action, punch_action: action,
}), }),
@@ -219,6 +245,8 @@ export default function Attendance() {
.catch(() => {}); .catch(() => {});
}, },
(geoError) => { (geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu"; let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) { if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut"; errorMsg = "Přístup k poloze byl zamítnut";
@@ -226,9 +254,10 @@ export default function Attendance() {
errorMsg = "Vypršel časový limit"; errorMsg = "Vypršel časový limit";
} }
alert.error(errorMsg); alert.error(errorMsg);
setGpsConfirm({ show: true, action }); setSubmitting(false);
setGpsConfirm({ isOpen: true, action });
}, },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
); );
}; };
@@ -249,7 +278,7 @@ export default function Attendance() {
if (result.success) { if (result.success) {
await fetchData(); await fetchData();
setTimeout(() => { punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo"); alert.success(result.data?.message || result.message || "Uloženo");
}, 300); }, 300);
} else { } else {
@@ -360,7 +389,7 @@ export default function Attendance() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setShowLeaveModal(false); setIsLeaveModalOpen(false);
await fetchData(); await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -605,12 +634,16 @@ export default function Attendance() {
{projectLogs.length > 0 && ( {projectLogs.length > 0 && (
<div className="attendance-project-logs"> <div className="attendance-project-logs">
{projectLogs.map((log, i) => { {projectLogs.map((log, i) => {
const start = new Date(log.started_at!); if (!log.started_at) return null;
const start = new Date(log.started_at);
const end = log.ended_at const end = log.ended_at
? new Date(log.ended_at) ? new Date(log.ended_at)
: new Date(); : new Date();
const mins = Math.floor( const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000, (end.getTime() - start.getTime()) / 60000,
),
); );
const h = Math.floor(mins / 60); const h = Math.floor(mins / 60);
const mm = mins % 60; const mm = mins % 60;
@@ -658,7 +691,7 @@ export default function Attendance() {
{submitting ? "Zpracovávám..." : "Odchod"} {submitting ? "Zpracovávám..." : "Odchod"}
</button> </button>
<button <button
onClick={() => setShowLeaveModal(true)} onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full" className="admin-btn admin-btn-secondary w-full"
> >
Žádost o nepřítomnost Žádost o nepřítomnost
@@ -697,7 +730,7 @@ export default function Attendance() {
</button> </button>
<button <button
onClick={() => setShowLeaveModal(true)} onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full" className="admin-btn admin-btn-secondary w-full"
> >
Žádost o nepřítomnost Žádost o nepřítomnost
@@ -754,11 +787,12 @@ export default function Attendance() {
}} }}
> >
{shiftLogs.map((log, i) => { {shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at const mins = log.ended_at
? Math.floor( ? Math.floor(
(new Date(log.ended_at).getTime() - (new Date(log.ended_at).getTime() -
new Date( new Date(
log.started_at!, log.started_at,
).getTime()) / ).getTime()) /
60000, 60000,
) )
@@ -1045,7 +1079,7 @@ export default function Attendance() {
{/* Leave Modal */} {/* Leave Modal */}
<AnimatePresence> <AnimatePresence>
{showLeaveModal && ( {isLeaveModalOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -1055,7 +1089,7 @@ export default function Attendance() {
> >
<div <div
className="admin-modal-backdrop" className="admin-modal-backdrop"
onClick={() => setShowLeaveModal(false)} onClick={() => setIsLeaveModalOpen(false)}
/> />
<motion.div <motion.div
className="admin-modal" className="admin-modal"
@@ -1176,7 +1210,7 @@ export default function Attendance() {
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button <button
type="button" type="button"
onClick={() => setShowLeaveModal(false)} onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
disabled={requestSubmitting} disabled={requestSubmitting}
> >
@@ -1203,13 +1237,13 @@ export default function Attendance() {
</AnimatePresence> </AnimatePresence>
<ConfirmModal <ConfirmModal
isOpen={gpsConfirm.show} isOpen={gpsConfirm.isOpen}
onClose={() => { onClose={() => {
setGpsConfirm({ show: false, action: null }); setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false); setSubmitting(false);
}} }}
onConfirm={() => { onConfirm={() => {
setGpsConfirm({ show: false, action: null }); setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {}); submitPunch(gpsConfirm.action!, {});
}} }}
title="GPS nedostupná" title="GPS nedostupná"

View File

@@ -405,7 +405,7 @@ export default function AttendanceAdmin() {
{/* Modals */} {/* Modals */}
<BulkAttendanceModal <BulkAttendanceModal
show={showBulkModal} isOpen={showBulkModal}
onClose={() => setShowBulkModal(false)} onClose={() => setShowBulkModal(false)}
form={bulkForm} form={bulkForm}
setForm={setBulkForm} setForm={setBulkForm}
@@ -418,7 +418,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal <ShiftFormModal
mode="create" mode="create"
show={showCreateModal} isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
form={createForm} form={createForm}
@@ -433,7 +433,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal <ShiftFormModal
mode="edit" mode="edit"
show={showEditModal && !!editingRecord} isOpen={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
form={editForm} form={editForm}

View File

@@ -224,9 +224,10 @@ export default function AttendanceBalances() {
}, [year]); }, [year]);
useEffect(() => { useEffect(() => {
fetchData(); const loadAll = async () => {
fetchFundData(); await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]);
fetchProjectData(); };
loadAll();
}, [fetchData, fetchFundData, fetchProjectData]); }, [fetchData, fetchFundData, fetchProjectData]);
useModalLock(showEditModal); useModalLock(showEditModal);

View File

@@ -39,9 +39,9 @@ export default function AttendanceCreate() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
return {
const [form, setForm] = useState<CreateForm>({
user_id: "", user_id: "",
shift_date: today, shift_date: today,
leave_type: "work", leave_type: "work",
@@ -55,6 +55,7 @@ export default function AttendanceCreate() {
departure_date: today, departure_date: today,
departure_time: "", departure_time: "",
notes: "", notes: "",
};
}); });
useEffect(() => { useEffect(() => {

View File

@@ -69,6 +69,73 @@ const formatBreakRange = (record: AttendanceRecord): string => {
return "—"; return "—";
}; };
function getEasterSunday(year: number): string {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function getCzechHolidays(year: number): string[] {
const y = String(year);
const holidays = [
`${y}-01-01`,
`${y}-05-01`,
`${y}-05-08`,
`${y}-07-05`,
`${y}-07-06`,
`${y}-09-28`,
`${y}-10-28`,
`${y}-11-17`,
`${y}-12-24`,
`${y}-12-25`,
`${y}-12-26`,
];
const easterSunday = getEasterSunday(year);
const easterDate = new Date(easterSunday);
const goodFriday = new Date(easterDate);
goodFriday.setDate(goodFriday.getDate() - 2);
const easterMonday = new Date(easterDate);
easterMonday.setDate(easterMonday.getDate() + 1);
const pad = (n: number) => String(n).padStart(2, "0");
holidays.push(
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
);
holidays.push(
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
);
holidays.sort();
return holidays;
}
function getBusinessDaysInMonth(year: number, month: number): number {
const holidays = getCzechHolidays(year);
let count = 0;
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (!holidays.includes(dateStr)) {
count++;
}
}
}
return count;
}
const renderProjectCell = (record: AttendanceRecord) => { const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) { if (record.project_logs && record.project_logs.length > 0) {
return ( return (
@@ -85,8 +152,11 @@ const renderProjectCell = (record: AttendanceRecord) => {
} else { } else {
isActive = !log.ended_at; isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date(); const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor( const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000, (end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
); );
h = Math.floor(mins / 60); h = Math.floor(mins / 60);
m = mins % 60; m = mins % 60;
@@ -182,7 +252,8 @@ export default function AttendanceHistory() {
if (leaveType === "work") { if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record); totalMinutes += calculateWorkMinutes(record);
} else { } else {
const hours = Number(record.leave_hours) || 8; const hours =
record.leave_hours != null ? Number(record.leave_hours) : 8;
if (leaveType === "vacation") vacationHours += hours; if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours; else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours; else if (leaveType === "holiday") holidayHours += hours;
@@ -190,21 +261,9 @@ export default function AttendanceHistory() {
} }
} }
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10); const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1; const mo = parseInt(monthStr, 10) - 1;
const holidayDays = records.filter( const businessDays = getBusinessDaysInMonth(yr, mo);
(r) => (r.leave_type || "work") === "holiday",
).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1);
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays);
const fund = businessDays * 8; const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100; const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary) // Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)

View File

@@ -134,9 +134,17 @@ export default function AttendanceLocation() {
fillOpacity: 0.8, fillOpacity: 0.8,
}).addTo(map); }).addTo(map);
marker.bindPopup( const popupEl = document.createElement("div");
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`, const strong = document.createElement("strong");
strong.textContent = loc.label;
popupEl.appendChild(strong);
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(document.createTextNode(loc.time));
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(
document.createTextNode(`Přesnost: ${Math.round(loc.accuracy)}m`),
); );
marker.bindPopup(popupEl);
if (loc.accuracy > 0) { if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], { L.circle([loc.lat, loc.lng], {

View File

@@ -3,6 +3,7 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
@@ -85,7 +86,6 @@ export default function CompanySettings({
vat_id: "", vat_id: "",
}); });
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const customFieldKeyCounter = useRef(0);
const [fieldOrder, setFieldOrder] = useState<string[]>([ const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER, ...DEFAULT_FIELD_ORDER,
]); ]);
@@ -99,6 +99,10 @@ export default function CompanySettings({
const [bankLoading, setBankLoading] = useState(true); const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false); const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null); const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
isOpen: boolean;
id: number | null;
}>({ isOpen: false, id: null });
const [bankForm, setBankForm] = useState<BankForm>({ const [bankForm, setBankForm] = useState<BankForm>({
account_name: "", account_name: "",
bank_name: "", bank_name: "",
@@ -197,9 +201,17 @@ export default function CompanySettings({
const cf = const cf =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0 Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map( ? d.custom_fields.map(
(f: { name: string; value: string; showLabel?: boolean }) => ({ (
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f, ...f,
_key: `cf-${++customFieldKeyCounter.current}`, _key: f._key || `cf-${Date.now()}-${i}`,
}), }),
) )
: []; : [];
@@ -293,22 +305,31 @@ export default function CompanySettings({
} }
}; };
const handleBankDelete = async (id: number) => { const handleBankDelete = (id: number) => {
if (!confirm("Opravdu smazat tento bankovní účet?")) return; setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try { try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, { const response = await apiFetch(
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
{
method: "DELETE", method: "DELETE",
}); },
);
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message); alert.success(result.message);
if (editingBank === id) resetBankForm(); if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts(); fetchBankAccounts();
} else { } else {
alert.error(result.error || "Chyba při mazání"); alert.error(result.error || "Chyba při mazání");
} }
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
} }
}; };
@@ -716,7 +737,7 @@ export default function CompanySettings({
name: "", name: "",
value: "", value: "",
showLabel: true, showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`, _key: `cf-${Date.now()}`,
}, },
]) ])
} }
@@ -1208,6 +1229,17 @@ export default function CompanySettings({
</button> </button>
</motion.div> </motion.div>
)} )}
<ConfirmModal
isOpen={bankDeleteConfirm.isOpen}
onClose={() => setBankDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmBankDelete}
title="Smazat bankovní účet"
message="Opravdu chcete smazat tento bankovní účet?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div> </div>
); );
} }

View File

@@ -129,7 +129,7 @@ export default function Dashboard() {
}, [fetch2FAStatus]); }, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu // Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => { const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival"; const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true); setPunching(true);
@@ -167,7 +167,7 @@ export default function Dashboard() {
() => submitPunch({}), () => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
); );
}; }, [dashData, alert, fetchDashboard]);
// 2FA handlery // 2FA handlery
const handleStart2FASetup = async () => { const handleStart2FASetup = async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -138,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth = const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear(); statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`; const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -194,7 +204,6 @@ export default function Invoices() {
}>({ show: false, invoice: null }); }>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null); const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [langModal, setLangModal] = useState<Invoice | null>(null);
const [draft, setDraft] = useState<DraftData | null>(() => { const [draft, setDraft] = useState<DraftData | null>(() => {
try { try {
const raw = localStorage.getItem(DRAFT_KEY); const raw = localStorage.getItem(DRAFT_KEY);
@@ -284,29 +293,27 @@ export default function Invoices() {
} }
}; };
const handlePdf = async (inv: Invoice, lang = "cs") => { const handlePdf = async (inv: Invoice) => {
if (pdfLoading) return; if (pdfLoading) return;
setLangModal(null); const newWindow = window.open("", "_blank");
setPdfLoading(inv.id); setPdfLoading(inv.id);
try { try {
const response = await apiFetch( const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`);
`${API_BASE}/invoices-pdf/${inv.id}?lang=${encodeURIComponent(lang)}`, if (response.status === 401) {
); newWindow?.close();
if (response.status === 401) return;
if (!response.ok) {
alert.error("Nepodařilo se vygenerovat PDF");
return; return;
} }
const html = await response.text(); if (!response.ok) {
const w = window.open("", "_blank"); newWindow?.close();
if (w) { alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji");
w.document.open(); return;
w.document.write(html);
w.document.close();
w.onload = () => w.print();
} else {
alert.error("Prohlížeč zablokoval vyskakovací okno");
} }
const blob = await response.blob();
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");
} finally { } finally {
@@ -996,9 +1003,14 @@ export default function Invoices() {
<Link <Link
to={`/invoices/${inv.id}`} to={`/invoices/${inv.id}`}
className="admin-btn-icon" className="admin-btn-icon"
title="Detail" title={
aria-label="Detail" inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
> >
{inv.status === "paid" ? (
<svg <svg
width="18" width="18"
height="18" height="18"
@@ -1010,12 +1022,25 @@ export default function Invoices() {
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link> </Link>
{hasPermission("invoices.export") && ( {hasPermission("invoices.export") && (
<button <button
onClick={() => setLangModal(inv)} onClick={() => handlePdf(inv)}
className="admin-btn-icon" className="admin-btn-icon"
title="PDF" title="Zobrazit fakturu"
disabled={pdfLoading === inv.id} disabled={pdfLoading === inv.id}
> >
{pdfLoading === inv.id ? ( {pdfLoading === inv.id ? (
@@ -1092,69 +1117,6 @@ export default function Invoices() {
type="danger" type="danger"
loading={deleting} loading={deleting}
/> />
<AnimatePresence>
{langModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setLangModal(null)}
/>
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">
V jakém jazyce chcete vygenerovat fakturu?
</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => handlePdf(langModal, "cs")}
className="admin-btn admin-btn-primary"
>
Čeština
</button>
<button
type="button"
onClick={() => handlePdf(langModal, "en")}
className="admin-btn admin-btn-primary"
>
English
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
)} )}
</div> </div>

View File

@@ -163,8 +163,8 @@ export default function LeaveApproval() {
: []), : []),
].sort( ].sort(
(a: LeaveRequest, b: LeaveRequest) => (a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() - (b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
new Date(a.reviewed_at!).getTime(), (a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
); );
setProcessedRequests(all); setProcessedRequests(all);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) { } else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni."); alert.success("Byli jste úspěšně odhlášeni.");
} }
}, [alert]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input // Auto-focus TOTP input
useEffect(() => { useEffect(() => {
@@ -43,21 +44,8 @@ export default function Login() {
} }
}, [show2FA, useBackupCode]); }, [show2FA, useBackupCode]);
if (authLoading) { const handleSubmit = useCallback(
return ( async (e: React.FormEvent) => {
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
);
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -78,9 +66,24 @@ export default function Login() {
setAnimatingOut(true); setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400); setTimeout(() => setAnimatingOut(false), 400);
} }
}; },
[
username,
password,
remember,
login,
alert,
setLoading,
setShake,
setAnimatingOut,
setLoginToken,
setShow2FA,
setTotpCode,
],
);
const handle2FASubmit = async (e: React.FormEvent) => { const handle2FASubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!totpCode.trim()) return; if (!totpCode.trim()) return;
@@ -105,14 +108,41 @@ export default function Login() {
setAnimatingOut(true); setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400); setTimeout(() => setAnimatingOut(false), 400);
} }
}; },
[
totpCode,
loginToken,
remember,
useBackupCode,
verify2FA,
alert,
setLoading,
setShake,
setTotpCode,
setAnimatingOut,
],
);
const handleBack = () => { const handleBack = useCallback(() => {
setShow2FA(false); setShow2FA(false);
setLoginToken(null); setLoginToken(null);
setTotpCode(""); setTotpCode("");
setUseBackupCode(false); setUseBackupCode(false);
}; }, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]);
if (authLoading) {
return (
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
);
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />;
}
return ( return (
<motion.div <motion.div

View File

@@ -2,6 +2,7 @@ import {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useMemo,
useRef, useRef,
type ChangeEvent, type ChangeEvent,
} from "react"; } from "react";
@@ -55,9 +56,6 @@ interface OfferItem {
is_included_in_total: boolean; is_included_in_total: boolean;
} }
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection { interface ScopeSection {
title: string; title: string;
title_cz: string; title_cz: string;
@@ -113,16 +111,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "", content: "",
}); });
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({ function SortableItemRow({
item, item,
index, index,
@@ -274,6 +262,19 @@ function SortableItemRow({
); );
} }
function loadOfferDraft(): {
form?: Record<string, unknown>;
items?: unknown[];
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem("boha_offer_draft");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function OfferDetail() { export default function OfferDetail() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
@@ -288,12 +289,56 @@ export default function OfferDetail() {
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
const itemKeyCounter = useRef(0);
const emptyItem = useCallback(
(): OfferItem => ({
_key: `item-${++itemKeyCounter.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
}),
[],
);
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm); const [form, setForm] = useState<OfferForm>(() => {
const [items, setItems] = useState<OfferItem[]>([emptyItem()]); const draft = loadOfferDraft();
const [sections, setSections] = useState<ScopeSection[]>([]); if (draft?.form) {
return {
...emptyForm,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
return draft.items as OfferItem[];
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [scopeTemplates, setScopeTemplates] = useState< const [scopeTemplates, setScopeTemplates] = useState<
Array<{ Array<{
id: number; id: number;
@@ -321,6 +366,7 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null); const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{ const [companySettings, setCompanySettings] = useState<{
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
@@ -333,33 +379,40 @@ export default function OfferDetail() {
full_name: string; full_name: string;
} | null>(null); } | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
useModalLock(showOrderModal); useModalLock(showOrderModal);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => { useEffect(() => {
apiFetch(`${API_BASE}/company-settings`) apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json()) .then((r) => r.json())
.then((d) => { .then((d) => {
if (d.success) setCompanySettings(d.data); if (d.success) {
}) setCompanySettings(d.data);
.catch(() => {}); if (!isEdit) {
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
currency: currency:
prev.currency === "CZK" prev.currency === "CZK"
? companySettings.default_currency || "CZK" ? d.data.default_currency || "CZK"
: prev.currency, : prev.currency,
vat_rate: vat_rate:
prev.vat_rate === 21 prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21) ? (d.data.default_vat_rate ?? 21)
: prev.vat_rate, : prev.vat_rate,
})); }));
} }
}, [companySettings, isEdit]); }
})
.catch(() => {});
}, []);
const isInvalidated = offerStatus === "invalidated"; const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy; const isLockedByOther = !!lockedBy;
@@ -378,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const d = result.data; const d = result.data;
setForm({ const formData = {
quotation_number: d.quotation_number || "", quotation_number: d.quotation_number || "",
project_code: d.project_code || "", project_code: d.project_code || "",
customer_id: d.customer_id || null, customer_id: d.customer_id || null,
@@ -394,21 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "", exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "", scope_title: d.scope_title || "",
scope_description: d.scope_description || "", scope_description: d.scope_description || "",
}); };
setItems( setForm(formData);
d.items?.length const mappedItems = d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() })) ? d.items.map((it: any) => ({
: [emptyItem()], ...it,
); _key: `item-${++itemKeyCounter.current}`,
setSections( }))
d.sections?.length : [emptyItem()];
setItems(mappedItems);
const mappedSections = d.sections?.length
? d.sections.map((s: any) => ({ ? d.sections.map((s: any) => ({
title: s.title || "", title: s.title || "",
title_cz: s.title_cz || "", title_cz: s.title_cz || "",
content: s.content || "", content: s.content || "",
})) }))
: [], : [];
); setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setOfferStatus(d.status || ""); setOfferStatus(d.status || "");
setOrderInfo(d.order || null); setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null); setLockedBy(d.locked_by || null);
@@ -447,10 +507,14 @@ export default function OfferDetail() {
return () => { return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
// Release lock on unmount // Release lock on unmount
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch( const controller = new AbortController();
() => {}, unlockAbortRef.current = controller;
); apiFetch(`${API_BASE}/offers/${id}/unlock`, {
method: "POST",
signal: controller.signal,
}).catch(() => {});
}; };
}, [isEdit, id, isLockedByOther, isInvalidated]); }, [isEdit, id, isLockedByOther, isInvalidated]);
@@ -458,6 +522,28 @@ export default function OfferDetail() {
if (isEdit) fetchDetail(); if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]); }, [isEdit, fetchDetail]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
useEffect(() => { useEffect(() => {
const loadCustomers = async () => { const loadCustomers = async () => {
try { try {
@@ -517,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber(); fetchNextNumber();
}, [isEdit]); }, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only) // Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections }); const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500); const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -635,14 +688,17 @@ export default function OfferDetail() {
setSaving(true); setSaving(true);
try { try {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const response = await apiFetch(url, { const payload: any = {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form, ...form,
items: items.map((item, i) => ({ ...item, position: i })), items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })), sections: sections.map((s, i) => ({ ...s, position: i })),
}), };
if (!isEdit) delete payload.quotation_number;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
@@ -666,6 +722,13 @@ export default function OfferDetail() {
if (!isEdit && result.data?.id) { if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`); navigate(`/offers/${result.data.id}`);
} }
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
}
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nabídku"); alert.error(result.error || "Nepodařilo se uložit nabídku");
} }
@@ -778,7 +841,8 @@ export default function OfferDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");
@@ -1016,13 +1080,12 @@ export default function OfferDetail() {
<input <input
type="text" type="text"
value={form.quotation_number} value={form.quotation_number}
onChange={(e) => readOnly
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
className="admin-form-input" className="admin-form-input"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/> />
</FormField> </FormField>
<FormField label="Kód projektu"> <FormField label="Kód projektu">

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -63,6 +63,16 @@ export default function Offers() {
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false); const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null); const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null); const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null); const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
@@ -237,9 +247,11 @@ export default function Offers() {
return; return;
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); if (blobUrlRef.current) {
if (newWindow) newWindow.location.href = url; URL.revokeObjectURL(blobUrlRef.current);
setTimeout(() => URL.revokeObjectURL(url), 60000); }
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");

View File

@@ -3,6 +3,7 @@ import {
useEffect, useEffect,
useCallback, useCallback,
useMemo, useMemo,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
@@ -11,6 +12,7 @@ import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import OrderConfirmationModal from "../components/OrderConfirmationModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -112,13 +114,20 @@ export default function OrderDetail() {
show: boolean; show: boolean;
status: string | null; status: string | null;
}>({ show: false, status: null }); }>({ show: false, status: null });
const [editingNumber, setEditingNumber] = useState(false);
const [orderNumber, setOrderNumber] = useState("");
const [savingNumber, setSavingNumber] = useState(false);
const [attachmentLoading, setAttachmentLoading] = useState(false); const [attachmentLoading, setAttachmentLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const fetchDetail = useCallback(async () => { const fetchDetail = useCallback(async () => {
try { try {
@@ -128,6 +137,7 @@ export default function OrderDetail() {
if (result.success) { if (result.success) {
setOrder(result.data); setOrder(result.data);
setNotes(result.data.notes || ""); setNotes(result.data.notes || "");
initialNotesRef.current = result.data.notes || "";
} else { } else {
alert.error(result.error || "Nepodařilo se načíst objednávku"); alert.error(result.error || "Nepodařilo se načíst objednávku");
navigate("/orders"); navigate("/orders");
@@ -144,6 +154,21 @@ export default function OrderDetail() {
fetchDetail(); fetchDetail();
}, [fetchDetail]); }, [fetchDetail]);
const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current;
}, [notes]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const totals = useMemo(() => { const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }; if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => { const subtotal = order.items.reduce((sum, item) => {
@@ -186,42 +211,6 @@ export default function OrderDetail() {
} }
}; };
const handleStartEditNumber = () => {
if (!order) return;
setOrderNumber(order.order_number);
setEditingNumber(true);
};
const handleSaveNumber = async () => {
if (!order) return;
const trimmed = orderNumber.trim();
if (!trimmed) return;
if (trimmed === order.order_number) {
setEditingNumber(false);
return;
}
setSavingNumber(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_number: trimmed }),
});
const result = await response.json();
if (result.success) {
alert.success("Číslo objednávky bylo změněno");
setEditingNumber(false);
fetchDetail();
} else {
alert.error(result.error || "Nepodařilo se změnit číslo");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSavingNumber(false);
}
};
const handleSaveNotes = async () => { const handleSaveNotes = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -233,6 +222,7 @@ export default function OrderDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success("Poznámky byly uloženy"); alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} else { } else {
alert.error(result.error || "Nepodařilo se uložit poznámky"); alert.error(result.error || "Nepodařilo se uložit poznámky");
} }
@@ -256,7 +246,8 @@ export default function OrderDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");
@@ -265,6 +256,50 @@ export default function OrderDetail() {
} }
}; };
const handleGenerateConfirmation = async (
lang: string,
applyVat: boolean,
customItems?: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}>,
) => {
setConfirmationLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/orders-pdf/${id}/confirmation`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lang, applyVat, items: customItems }),
},
);
if (!response.ok) {
const result = await response.json().catch(() => ({}));
alert.error(result.error || "Nepodařilo se vygenerovat PDF");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Potvrzeni-${order?.order_number || String(id)}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
alert.error("Chyba připojení");
} finally {
setConfirmationLoading(false);
}
};
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true);
try { try {
@@ -361,102 +396,7 @@ export default function OrderDetail() {
</Link> </Link>
<div> <div>
<h1 className="admin-page-title flex-row-gap"> <h1 className="admin-page-title flex-row-gap">
{editingNumber ? ( <span>Objednávka {order.order_number}</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
Objednávka
<input
type="text"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveNumber();
if (e.key === "Escape") setEditingNumber(false);
}}
className="admin-form-input"
style={{
width: "10rem",
fontSize: "1rem",
padding: "0.25rem 0.5rem",
height: "auto",
}}
autoFocus
disabled={savingNumber}
/>
<button
onClick={handleSaveNumber}
className="admin-btn-icon"
title="Uložit"
aria-label="Uložit"
disabled={savingNumber}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent-color)"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button
onClick={() => setEditingNumber(false)}
className="admin-btn-icon"
title="Zrušit"
aria-label="Zrušit"
disabled={savingNumber}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
) : (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
Objednávka {order.order_number}
{hasPermission("orders.edit") && (
<button
onClick={handleStartEditNumber}
className="admin-btn-icon"
title="Změnit číslo"
aria-label="Změnit číslo"
style={{ opacity: 0.5 }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
</span>
)}
<span <span
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`} className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
> >
@@ -506,6 +446,24 @@ export default function OrderDetail() {
</Link> </Link>
) )
)} )}
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Potvrzení objednávky
</button>
{hasPermission("orders.edit") && {hasPermission("orders.edit") &&
order.valid_transitions?.filter((s) => s !== "stornovana").length! > order.valid_transitions?.filter((s) => s !== "stornovana").length! >
0 && 0 &&
@@ -900,6 +858,26 @@ export default function OrderDetail() {
type="danger" type="danger"
loading={deleting} loading={deleting}
/> />
{/* Order confirmation PDF modal */}
{order && (
<OrderConfirmationModal
isOpen={showConfirmationModal}
onClose={() => setShowConfirmationModal(false)}
onGenerate={handleGenerateConfirmation}
initialItems={order.items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: Number(it.is_included_in_total) !== 0,
vat_rate: Number(order.vat_rate) || 21,
}))}
orderNumber={order.order_number}
defaultVatRate={Number(order.vat_rate) || 21}
applyVat={!!order.apply_vat}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,380 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Customer {
id: number;
name: string;
company_id?: string;
city?: string;
}
interface User {
id: number;
name: string;
}
interface ProjectForm {
project_number: string;
name: string;
customer_id: number | null;
customer_name: string;
start_date: string;
responsible_user_id: string;
}
export default function ProjectCreate() {
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const [form, setForm] = useState<ProjectForm>({
project_number: "",
name: "",
customer_id: null,
customer_name: "",
start_date: new Date().toISOString().split("T")[0],
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [loadingNumber, setLoadingNumber] = useState(true);
// Customer selector state
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
// Load initial data
useEffect(() => {
const load = async () => {
try {
const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/users`),
]);
const numData = await numRes.json();
if (numData.success) {
setForm((prev) => ({
...prev,
project_number:
numData.data?.next_number || numData.data?.number || "",
}));
}
const custData = await custRes.json();
if (custData.success) {
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.items || [],
);
}
const usersData = await usersRes.json();
if (usersData.success) {
const rawUsers = Array.isArray(usersData.data)
? usersData.data
: usersData.data?.items || [];
setUsers(
rawUsers.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
alert.error("Chyba při načítání dat");
} finally {
setLoadingNumber(false);
}
};
load();
}, [alert]);
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers;
const q = customerSearch.toLowerCase();
return customers.filter(
(c) =>
(c.name || "").toLowerCase().includes(q) ||
(c.company_id || "").includes(customerSearch) ||
(c.city || "").toLowerCase().includes(q),
);
}, [customers, customerSearch]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown]);
if (!hasPermission("projects.create")) return <Forbidden />;
const selectCustomer = (customer: Customer) => {
setForm((prev) => ({
...prev,
customer_id: customer.id,
customer_name: customer.name,
}));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateForm = (field: keyof ProjectForm, value: unknown) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Název projektu je povinný";
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null,
};
const res = await apiFetch(`${API_BASE}/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.project_id}`, {
state: { created: true },
});
} else {
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
if (loadingNumber) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">Nový projekt</h1>
<p className="admin-page-subtitle">Ruční vytvoření projektu</p>
</div>
</div>
<div className="admin-page-actions">
<button
onClick={handleSave}
disabled={saving}
className="admin-btn admin-btn-primary"
>
{saving ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ overflow: "visible" }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo projektu">
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm("project_number", e.target.value)}
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
<button
type="button"
onClick={clearCustomer}
className="admin-btn-icon"
title="Odebrat zákazníka"
aria-label="Odebrat zákazníka"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div
className="admin-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
/>
{showCustomerDropdown && (
<div className="admin-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="admin-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="admin-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm("start_date", val)}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -73,7 +73,6 @@ export default function ProjectDetail() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission, isAdmin } = useAuth(); const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -98,18 +97,6 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false); const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null); const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const createdShown = useRef(false);
useEffect(() => {
if (
(location.state as { created?: boolean })?.created &&
!createdShown.current
) {
createdShown.current = true;
alert.success("Projekt byl vytvořen");
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, location.pathname, alert, navigate]);
const fetchNotes = async () => { const fetchNotes = async () => {
try { try {
const response = await apiFetch(`${API_BASE}/projects/${id}`); const response = await apiFetch(`${API_BASE}/projects/${id}`);

View File

@@ -160,22 +160,6 @@ export default function Projects() {
)} )}
</p> </p>
</div> </div>
{hasPermission("projects.create") && (
<Link to="/projects/new" className="admin-btn admin-btn-primary">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nový projekt
</Link>
)}
</motion.div> </motion.div>
<motion.div <motion.div

View File

@@ -161,6 +161,7 @@ export default function ReceivedInvoices({
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
const prevMonth = useRef(statsMonth); const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear); const prevYear = useRef(statsYear);
@@ -186,17 +187,19 @@ export default function ReceivedInvoices({
useModalLock(uploadOpen || editOpen); useModalLock(uploadOpen || editOpen);
useEffect(() => { useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current; return () => {
const curr = statsYear * 12 + statsMonth; blobTimeoutsRef.current.forEach(clearTimeout);
if (curr > prev) { };
slideDirection.current = 1; }, []);
}
if (curr < prev) { // Compute slide direction during render (not in effect) so it's
slideDirection.current = -1; // available for the current frame instead of one render late.
} const prevTotal = prevYear.current * 12 + prevMonth.current;
const currTotal = statsYear * 12 + statsMonth;
if (currTotal > prevTotal) slideDirection.current = 1;
else if (currTotal < prevTotal) slideDirection.current = -1;
prevMonth.current = statsMonth; prevMonth.current = statsMonth;
prevYear.current = statsYear; prevYear.current = statsYear;
}, [statsMonth, statsYear]);
const fetchList = useCallback(async () => { const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true); if (!hasLoadedOnce.current) setLoading(true);
@@ -516,7 +519,8 @@ export default function ReceivedInvoices({
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");

View File

@@ -76,6 +76,7 @@ export default function Settings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]); const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState< const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]> Record<string, Permission[]>
@@ -161,12 +162,14 @@ export default function Settings() {
return; return;
} }
try { try {
const [rolesRes, permsRes] = await Promise.all([ const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`), apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]); ]);
const rolesResult = await rolesRes.json(); const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json(); const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (rolesResult.success) { if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []); setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
@@ -188,6 +191,10 @@ export default function Settings() {
} }
setPermissionGroups(groups); setPermissionGroups(groups);
} }
if (usersResult.success) {
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
}
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally { } finally {
@@ -808,7 +815,7 @@ export default function Settings() {
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-secondary"> <span className="admin-badge admin-badge-secondary">
{0} {users.filter((u) => u.role_id === role.id).length}
</span> </span>
</td> </td>
<td> <td>
@@ -838,16 +845,21 @@ export default function Settings() {
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title={ title={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
aria-label={ aria-label={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
disabled={0 > 0} disabled={
users.filter((u) => u.role_id === role.id)
.length > 0
}
> >
<svg <svg
width="16" width="16"

View File

@@ -173,14 +173,7 @@ export default function Vehicles() {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, { const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ is_active: !vehicle.is_active }),
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || "",
model: vehicle.model || "",
initial_km: vehicle.initial_km,
is_active: !vehicle.is_active,
}),
}); });
const result = await response.json(); const result = await response.json();

View File

@@ -1,39 +1,55 @@
let showSessionExpiredAlert = false; class ApiState {
let showLogoutAlert = false; showSessionExpiredAlert = false;
let getTokenFn: (() => string | null) | null = null; showLogoutAlert = false;
let refreshFn: (() => Promise<boolean>) | null = null; getTokenFn: (() => string | null) | null = null;
let refreshPromise: Promise<boolean> | null = null; refreshFn: (() => Promise<boolean>) | null = null;
refreshPromise: Promise<boolean> | null = null;
reset() {
this.showSessionExpiredAlert = false;
this.showLogoutAlert = false;
this.getTokenFn = null;
this.refreshFn = null;
this.refreshPromise = null;
}
}
const state = new ApiState();
export const resetApiState = (): void => {
state.reset();
};
export const shouldShowSessionExpiredAlert = (): boolean => { export const shouldShowSessionExpiredAlert = (): boolean => {
if (showSessionExpiredAlert) { if (state.showSessionExpiredAlert) {
showSessionExpiredAlert = false; state.showSessionExpiredAlert = false;
return true; return true;
} }
return false; return false;
}; };
export const setSessionExpired = (): void => { export const setSessionExpired = (): void => {
showSessionExpiredAlert = true; state.showSessionExpiredAlert = true;
}; };
export const shouldShowLogoutAlert = (): boolean => { export const shouldShowLogoutAlert = (): boolean => {
if (showLogoutAlert) { if (state.showLogoutAlert) {
showLogoutAlert = false; state.showLogoutAlert = false;
return true; return true;
} }
return false; return false;
}; };
export const setLogoutAlert = (): void => { export const setLogoutAlert = (): void => {
showLogoutAlert = true; state.showLogoutAlert = true;
}; };
export const setTokenGetter = (fn: () => string | null): void => { export const setTokenGetter = (fn: () => string | null): void => {
getTokenFn = fn; state.getTokenFn = fn;
}; };
export const setRefreshFn = (fn: () => Promise<boolean>): void => { export const setRefreshFn = (fn: () => Promise<boolean>): void => {
refreshFn = fn; state.refreshFn = fn;
}; };
export const apiFetch = async ( export const apiFetch = async (
@@ -42,7 +58,7 @@ export const apiFetch = async (
): Promise<Response> => { ): Promise<Response> => {
let token: string | null = null; let token: string | null = null;
try { try {
token = getTokenFn ? getTokenFn() : null; token = state.getTokenFn ? state.getTokenFn() : null;
} catch { } catch {
// token retrieval failed // token retrieval failed
} }
@@ -69,21 +85,22 @@ export const apiFetch = async (
credentials: "include", credentials: "include",
}); });
if (response.status === 401 && refreshFn) { if (response.status === 401 && state.refreshFn) {
try { try {
if (!refreshPromise) { if (!state.refreshPromise) {
refreshPromise = refreshFn().finally(() => { state.refreshPromise = state.refreshFn().finally(() => {
refreshPromise = null; state.refreshPromise = null;
}); });
} }
const refreshed = await refreshPromise; const refreshed = await state.refreshPromise;
if (refreshed) { if (refreshed) {
token = getTokenFn ? getTokenFn() : null; token = state.getTokenFn ? state.getTokenFn() : null;
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const { signal, ...retryOptions } = options;
response = await fetch(url, { response = await fetch(url, {
...options, ...retryOptions,
headers, headers,
credentials: "include", credentials: "include",
}); });
@@ -100,7 +117,7 @@ export const apiFetch = async (
export const getAccessToken = (): string | null => { export const getAccessToken = (): string | null => {
try { try {
return getTokenFn ? getTokenFn() : null; return state.getTokenFn ? state.getTokenFn() : null;
} catch { } catch {
return null; return null;
} }

View File

@@ -5,6 +5,8 @@ dotenv.config();
process.env.TZ = process.env.TZ || "Europe/Prague"; process.env.TZ = process.env.TZ || "Europe/Prague";
// Override Date.toJSON so JSON.stringify outputs local time (Europe/Prague). // Override Date.toJSON so JSON.stringify outputs local time (Europe/Prague).
// This is intentional and required for PHP migration compatibility: the legacy
// PHP API returned local Czech times, and the frontend relies on this format.
// Prisma stores UTC in MySQL DATETIME columns. When reading, it creates // Prisma stores UTC in MySQL DATETIME columns. When reading, it creates
// JS Date objects with correct UTC internals. The default toJSON() calls // JS Date objects with correct UTC internals. The default toJSON() calls
// toISOString() which returns UTC — this override uses local getters instead, // toISOString() which returns UTC — this override uses local getters instead,
@@ -50,13 +52,27 @@ export const config = {
totp: { totp: {
encryptionKey: required("TOTP_ENCRYPTION_KEY"), encryptionKey: required("TOTP_ENCRYPTION_KEY"),
algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1",
digits: Math.max(6, parseInt(process.env.TOTP_DIGITS || "6", 10) || 6),
period: Math.max(15, parseInt(process.env.TOTP_PERIOD || "30", 10) || 30),
loginTokenExpiryMinutes: Math.max(
1,
parseInt(process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", 10) || 5,
),
}, },
nas: { nas: {
path: process.env.NAS_PATH || "Z:/02_PROJEKTY", path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
financialsPath: process.env.NAS_FINANCIALS_PATH || "", financialsPath: process.env.NAS_FINANCIALS_PATH || "",
offersPath: process.env.NAS_OFFERS_PATH || "", offersPath: process.env.NAS_OFFERS_PATH || "",
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10), maxUploadSize: (() => {
const parsed = parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.warn("Invalid MAX_UPLOAD_SIZE, using default 52428800");
return 52428800;
}
return parsed;
})(),
}, },
email: { email: {
@@ -74,7 +90,41 @@ export const config = {
origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean), origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean),
}, },
trustProxy: (process.env.TRUST_PROXY || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
security: { security: {
bcryptCost: 12, bcryptCost: 12,
}, },
} as const; } as const;
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
if (!HEX64_RE.test(config.jwt.secret)) {
throw new Error("JWT_SECRET must be a 64-character hex string");
}
if (!HEX64_RE.test(config.totp.encryptionKey)) {
throw new Error("TOTP_ENCRYPTION_KEY must be a 64-character hex string");
}
if (Number.isNaN(config.port) || config.port < 1 || config.port > 65535) {
throw new Error("PORT must be a valid TCP port (1-65535)");
}
if (
Number.isNaN(config.jwt.accessTokenExpiry) ||
config.jwt.accessTokenExpiry <= 0
) {
throw new Error("ACCESS_TOKEN_EXPIRY must be a positive integer");
}
if (
Number.isNaN(config.jwt.refreshTokenSessionExpiry) ||
config.jwt.refreshTokenSessionExpiry <= 0
) {
throw new Error("REFRESH_TOKEN_SESSION_EXPIRY must be a positive integer");
}
if (
Number.isNaN(config.jwt.refreshTokenRememberExpiry) ||
config.jwt.refreshTokenRememberExpiry <= 0
) {
throw new Error("REFRESH_TOKEN_REMEMBER_EXPIRY must be a positive integer");
}

View File

@@ -12,6 +12,8 @@ export async function securityHeaders(
"Permissions-Policy", "Permissions-Policy",
"camera=(), microphone=(), geolocation=(self)", "camera=(), microphone=(), geolocation=(self)",
); );
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
if (config.isProduction) { if (config.isProduction) {
reply.header( reply.header(
@@ -27,6 +29,9 @@ export async function securityHeaders(
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org", "img-src 'self' data: blob: https://*.tile.openstreetmap.org",
"connect-src 'self' https://nominatim.openstreetmap.org", "connect-src 'self' https://nominatim.openstreetmap.org",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
].join("; "), ].join("; "),
); );
} }

View File

@@ -100,6 +100,9 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users --- // --- action=balances: leave balance overview for all users ---
if (action === "balances") { if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr); const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -107,6 +110,9 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview --- // --- action=workfund: monthly work fund overview ---
if (action === "workfund") { if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr); const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -114,6 +120,9 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours --- // --- action=project_report: monthly project hours ---
if (action === "project_report") { if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr); const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -135,21 +144,21 @@ export default async function attendanceRoutes(
// --- action=attendance_users: users with attendance.record permission --- // --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") { if (action === "attendance_users") {
if (
!authData.permissions.includes("attendance.admin") &&
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const users = await prisma.users.findMany({ const users = await prisma.users.findMany({
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "attendance.record" } }, some: { permissions: { name: "attendance.record" } },
}, },
}, },
],
},
},
}, },
select: { id: true, first_name: true, last_name: true, username: true }, select: { id: true, first_name: true, last_name: true, username: true },
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
@@ -173,6 +182,12 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record --- // --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") { if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id); const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Missing attendance_id", 400); if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId); const data = await attendanceService.getProjectLogs(attendanceId);
@@ -185,6 +200,10 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400); if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id); const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404); if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
return reply.send({ success: true, data: record }); return reply.send({ success: true, data: record });
} }
@@ -294,6 +313,14 @@ export default async function attendanceRoutes(
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400); if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data; const leaveBody = leaveParsed.data;
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createLeave( const result = await attendanceService.createLeave(
{ {
user_id: leaveBody.user_id, user_id: leaveBody.user_id,
@@ -342,6 +369,14 @@ export default async function attendanceRoutes(
if ("error" in stdParsed) return error(reply, stdParsed.error, 400); if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data; const body = stdParsed.data;
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createAttendance( const result = await attendanceService.createAttendance(
{ {
user_id: body.user_id, user_id: body.user_id,
@@ -364,6 +399,8 @@ export default async function attendanceRoutes(
}, },
authData.userId, authData.userId,
); );
if ("error" in result)
return error(reply, result.error!, result.status ?? 400);
await logAudit({ await logAudit({
request, request,
@@ -380,7 +417,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id // PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>( fastify.put<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("attendance.edit") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, paginated, error } from "../../utils/response"; import { success, paginated, error } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -53,6 +54,13 @@ export default async function auditLogRoutes(
// days === 0 means "delete all" (from frontend "Vše" option) // days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === "all") { if (days === 0 || body.action === "all") {
const result = await prisma.audit_logs.deleteMany({}); const result = await prisma.audit_logs.deleteMany({});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "audit_logs",
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal všechny audit logy, počet: ${result.count}`,
});
return success(reply, null, 200, `Smazáno ${result.count} záznamů`); return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
} }
@@ -62,6 +70,13 @@ export default async function auditLogRoutes(
const result = await prisma.audit_logs.deleteMany({ const result = await prisma.audit_logs.deleteMany({
where: { created_at: { lt: cutoff } }, where: { created_at: { lt: cutoff } },
}); });
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "audit_logs",
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal audit logy starší než ${days} dní, počet: ${result.count}`,
});
return success( return success(
reply, reply,
null, null,

View File

@@ -92,43 +92,101 @@ export default async function authRoutes(
// POST /api/admin/login/totp // POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>( fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp", "/login/totp",
{ bodyLimit: 10240 }, {
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body); const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data; const { login_token, totp_code, remember_me } = parsed.data;
const rawBody = request.body as unknown as Record<string, unknown>; const rememberMe = remember_me ?? false;
const rememberMe =
rawBody.remember_me === true || rawBody.remember_me === "true";
const tokenHash = crypto const tokenHash = crypto
.createHash("sha256") .createHash("sha256")
.update(login_token) .update(login_token)
.digest("hex"); .digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const totpResult = await prisma.$transaction(async (tx) => {
where: { token_hash: tokenHash }, const tokens = await tx.$queryRaw<
}); Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) { if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, "Neplatný nebo expirovaný login token", 401); return { error: "Neplatný nebo expirovaný login token", status: 401 };
} }
const user = await prisma.users.findUnique({ // $queryRaw on MySQL may return BigInt for integer columns
where: { id: storedToken.user_id }, const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id);
const user = await tx.users.findUnique({
where: { id: storedUserId },
include: { roles: true }, include: { roles: true },
}); });
if (!user || !user.totp_secret) { if (!user || !user.totp_secret) {
return { error: "Uživatel nenalezen", status: 401 };
}
if (!user.is_active) {
return { error: "Účet je deaktivován", status: 401 };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 };
}
return { user, storedTokenId };
});
if ("error" in totpResult) {
return error(reply, totpResult.error!, totpResult.status!);
}
const user = totpResult.user;
if (!user.totp_secret) {
return error(reply, "Uživatel nenalezen", 401); return error(reply, "Uživatel nenalezen", 401);
} }
const isValid = OTPAuth.verify(user.totp_secret, totp_code); const verifyResult = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) { if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 401); return error(reply, "Neplatný TOTP kód", 401);
} }
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } }); // Reject replayed TOTP codes
const replayCheck = await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{ totp_last_used_counter: number | null }>
>`SELECT totp_last_used_counter FROM users WHERE id = ${user.id} FOR UPDATE`;
const lastCounter = rows[0]?.totp_last_used_counter ?? null;
if (
lastCounter !== null &&
verifyResult.counter !== null &&
verifyResult.counter <= lastCounter
) {
return { replay: true };
}
await tx.$executeRaw`UPDATE users SET totp_last_used_counter = ${verifyResult.counter} WHERE id = ${user.id}`;
return { replay: false };
});
if (replayCheck.replay) {
return error(reply, "TOTP kód již byl použit", 401);
}
// TOTP verified successfully — now consume the login token
await prisma.totp_login_tokens.delete({
where: { id: totpResult.storedTokenId },
});
// Reset failed attempts and update last login (TOTP verified = successful login) // Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({ await prisma.users.update({
@@ -181,12 +239,33 @@ export default async function authRoutes(
}); });
setRefreshCookie(reply, refreshTokenRaw, rememberMe); setRefreshCookie(reply, refreshTokenRaw, rememberMe);
await logAudit({
request,
authData: authData,
action: "login_totp",
entityType: "user",
entityId: user.id,
description: `TOTP přihlášení uživatele ${user.username}`,
});
return success(reply, { access_token: accessToken, user: authData }); return success(reply, { access_token: accessToken, user: authData });
}, },
); );
// POST /api/admin/refresh // POST /api/admin/refresh
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => { fastify.post(
"/refresh",
{
config: {
rateLimit: {
max: 10,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) { if (!refreshTokenRaw) {
return error(reply, "Refresh token chybí", 401); return error(reply, "Refresh token chybí", 401);
@@ -210,10 +289,11 @@ export default async function authRoutes(
access_token: result.accessToken, access_token: result.accessToken,
user: result.user, user: result.user,
}); });
}); },
);
// POST /api/admin/logout // POST /api/admin/logout
fastify.post("/logout", async (request, reply) => { fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) { if (refreshTokenRaw) {
await logout(refreshTokenRaw); await logout(refreshTokenRaw);

View File

@@ -1,6 +1,10 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth"; import {
requireAuth,
requirePermission,
optionalAuth,
} from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
@@ -60,7 +64,7 @@ export default async function companySettingsRoutes(
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
// GET /api/admin/company-settings/logo?variant=light|dark // GET /api/admin/company-settings/logo?variant=light|dark
fastify.get("/logo", { preHandler: requireAuth }, async (request, reply) => { fastify.get("/logo", { preHandler: optionalAuth }, async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const variant = query.variant === "dark" ? "dark" : "light"; const variant = query.variant === "dark" ? "dark" : "light";
const column = variant === "dark" ? "logo_data_dark" : "logo_data"; const column = variant === "dark" ? "logo_data_dark" : "logo_data";
@@ -74,6 +78,17 @@ export default async function companySettingsRoutes(
let mime = "image/png"; let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
)
mime = "image/webp";
return reply return reply
.type(mime) .type(mime)
@@ -124,6 +139,7 @@ export default async function companySettingsRoutes(
); );
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => { fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
// Use upsert to avoid race condition between findFirst and create
let settings = await prisma.company_settings.findFirst({ let settings = await prisma.company_settings.findFirst({
select: { select: {
id: true, id: true,
@@ -165,7 +181,56 @@ export default async function companySettingsRoutes(
}); });
if (!settings) { if (!settings) {
settings = await prisma.company_settings.create({ // Wrap create in a transaction to handle race condition:
// another request may have created settings between findFirst and create
settings = await prisma.$transaction(async (tx) => {
// Double-check inside transaction
const existing = await tx.company_settings.findFirst({
select: { id: true },
});
if (existing) {
return tx.company_settings.findUnique({
where: { id: existing.id },
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
break_threshold_hours: true,
break_duration_short: true,
break_duration_long: true,
clock_rounding_minutes: true,
invoice_alert_email: true,
leave_notify_email: true,
max_login_attempts: true,
lockout_minutes: true,
max_requests_per_minute: true,
available_vat_rates: true,
available_currencies: true,
smtp_from: true,
smtp_from_name: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
});
}
return tx.company_settings.create({
data: { data: {
company_name: "", company_name: "",
quotation_prefix: "N", quotation_prefix: "N",
@@ -210,6 +275,7 @@ export default async function companySettingsRoutes(
invoice_number_pattern: true, invoice_number_pattern: true,
}, },
}); });
});
} }
if (!settings) return error(reply, "Nastavení nenalezeno", 500); if (!settings) return error(reply, "Nastavení nenalezeno", 500);
@@ -324,15 +390,12 @@ export default async function companySettingsRoutes(
nas: { nas: {
projects: { projects: {
configured: projectNas.isConfigured(), configured: projectNas.isConfigured(),
path: config.nas.path || "—",
}, },
financials: { financials: {
configured: nasFinancialsManager.isConfigured(), configured: nasFinancialsManager.isConfigured(),
path: config.nas.financialsPath || "—",
}, },
offers: { offers: {
configured: nasOffersManager.isConfigured(), configured: nasOffersManager.isConfigured(),
path: config.nas.offersPath || "—",
}, },
}, },
}); });
@@ -421,7 +484,7 @@ export default async function companySettingsRoutes(
: existingOrder, : existingOrder,
); );
} }
data.sync_version = (existing.sync_version ?? 0) + 1; data.sync_version = { increment: 1 };
await prisma.company_settings.update({ await prisma.company_settings.update({
where: { id: existing.id }, where: { id: existing.id },

View File

@@ -56,7 +56,10 @@ function decodeCustomFields(raw: string | null): {
export default async function customersRoutes( export default async function customersRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { fastify.get(
"/",
{ preHandler: requirePermission("customers.view") },
async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination( const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>, request.query as Record<string, unknown>,
); );
@@ -99,11 +102,12 @@ export default async function customersRoutes(
data: enriched, data: enriched,
pagination: buildPaginationMeta(total, page, limit), pagination: buildPaginationMeta(total, page, limit),
}); });
}); },
);
fastify.get<{ Params: { id: string } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("customers.view") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

@@ -179,43 +179,51 @@ export default async function dashboardRoutes(
// Invoices — only for invoices.view // Invoices — only for invoices.view
if (has("invoices.view")) { if (has("invoices.view")) {
const [unpaidCount, issuedThisMonth] = await Promise.all([ // $queryRaw template literal interpolation with Date objects fails on
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
const [unpaidCount, revenueAgg] = await Promise.all([
prisma.invoices.count({ where: { status: "issued" } }), prisma.invoices.count({ where: { status: "issued" } }),
prisma.invoices.findMany({ prisma.$queryRaw<
where: { issue_date: { gte: monthStart, lt: monthEnd } }, Array<{ currency: string | null; total: string | number | null }>
include: { invoice_items: true }, >`
}), SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total
FROM invoices i
JOIN invoice_items ii ON i.id = ii.invoice_id
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
GROUP BY i.currency
`,
]); ]);
const revenueByCurrency: Record<string, number> = {}; const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedThisMonth) { for (const row of revenueAgg) {
const currency = inv.currency ?? "CZK"; const currency = row.currency || "CZK";
let total = 0; const amount = Number(row.total) || 0;
for (const item of inv.invoice_items) {
total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
revenueByCurrency[currency] = revenueByCurrency[currency] =
(revenueByCurrency[currency] ?? 0) + total; (revenueByCurrency[currency] || 0) + amount;
} }
result.invoices = { const revenueConversions = await Promise.all(
revenue_this_month: Object.entries(revenueByCurrency).map( Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
([currency, amount]) => ({
amount: Math.round(amount * 100) / 100, amount: Math.round(amount * 100) / 100,
currency, currency,
}), czk: await toCzk(Math.round(amount * 100) / 100, currency),
), })),
);
result.invoices = {
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
amount,
currency,
})),
unpaid_count: unpaidCount, unpaid_count: unpaidCount,
revenue_czk: await (async () => { revenue_czk:
let total = 0; Math.round(
for (const [cur, amount] of Object.entries(revenueByCurrency)) { revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
total += await toCzk(Math.round(amount * 100) / 100, cur); ) / 100,
}
return Math.round(total * 100) / 100;
})(),
}; };
result.unpaid_invoices = unpaidCount;
} }
// Orders — only for orders.view // Orders — only for orders.view
@@ -231,7 +239,6 @@ export default async function dashboardRoutes(
where: { status: "pending" }, where: { status: "pending" },
}); });
result.leave_pending = { count }; result.leave_pending = { count };
result.pending_leave_requests = count;
} }
// Recent activity — only for settings.audit (admin) // Recent activity — only for settings.audit (admin)

View File

@@ -7,6 +7,12 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates"; import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date"; import { localDateStr } from "../../utils/date";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── Helpers ─────────────────────────────────────────────────────── */
@@ -48,8 +54,16 @@ function cleanQuillHtml(html: string | null | undefined): string {
); );
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {
prev = s; prev = s;
@@ -78,7 +92,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { if (raw) {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = cfData =
@@ -248,10 +267,13 @@ export default async function invoicesPdfRoutes(
): Promise<void> { ): Promise<void> {
fastify.get<{ Params: { id: string } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requirePermission("invoices.export") }, { preHandler: requirePermission("invoices.view") },
async (request, reply) => { async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
try {
const lang = query.lang === "en" ? "en" : "cs"; const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang]; const t = translations[lang];
@@ -284,6 +306,7 @@ export default async function invoicesPdfRoutes(
> | null; > | null;
let orderNumber = ""; let orderNumber = "";
let orderDate = "";
if (invoice.order_id) { if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({ const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id }, where: { id: invoice.order_id },
@@ -299,6 +322,9 @@ export default async function invoicesPdfRoutes(
orderRow.customer_order_number || orderRow.order_number || "", orderRow.customer_order_number || orderRow.order_number || "",
), ),
); );
if (orderRow.created_at) {
orderDate = formatDate(orderRow.created_at);
}
} }
} }
@@ -327,7 +353,8 @@ export default async function invoicesPdfRoutes(
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal; vatSummary[key].base += lineSubtotal;
if (applyVat) { if (applyVat) {
vatSummary[key].vat += (lineSubtotal * rate) / 100; vatSummary[key].vat +=
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
} }
} }
@@ -468,7 +495,7 @@ export default async function invoicesPdfRoutes(
<!-- Poznamky --> <!-- Poznamky -->
<div class="invoice-notes"> <div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> <div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div> <div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div> </div>
` `
: ""; : "";
@@ -491,14 +518,14 @@ export default async function invoicesPdfRoutes(
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 12mm 15mm 15mm 15mm; margin: 8mm 12mm 10mm 12mm;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif; font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 9pt; font-size: 10pt;
color: #1a1a1a; color: #1a1a1a;
width: 180mm; width: 186mm;
} }
.invoice-page { .invoice-page {
@@ -509,8 +536,6 @@ export default async function invoicesPdfRoutes(
.invoice-content { flex: 1 1 auto; } .invoice-content { flex: 1 1 auto; }
.invoice-footer { .invoice-footer {
flex-shrink: 0; flex-shrink: 0;
page-break-inside: avoid;
break-inside: avoid;
} }
.accent { color: #de3a3a; } .accent { color: #de3a3a; }
@@ -520,8 +545,8 @@ export default async function invoicesPdfRoutes(
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 3mm; margin-bottom: 1mm;
padding-bottom: 3mm; padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a; border-bottom: 2pt solid #de3a3a;
} }
.invoice-header .left { .invoice-header .left {
@@ -553,10 +578,10 @@ export default async function invoicesPdfRoutes(
border: 0.5pt solid #d0d0d0; border: 0.5pt solid #d0d0d0;
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin-bottom: 3mm; margin-bottom: 1mm;
} }
.header-grid td { .header-grid td {
padding: 3mm 4mm; padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0; border: 0.5pt solid #d0d0d0;
vertical-align: top; vertical-align: top;
width: 50%; width: 50%;
@@ -568,7 +593,7 @@ export default async function invoicesPdfRoutes(
background: #f5f5f5; background: #f5f5f5;
} }
.address-label { .address-label {
font-size: 7pt; font-size: 8pt;
font-weight: 700; font-weight: 700;
color: #de3a3a; color: #de3a3a;
text-transform: uppercase; text-transform: uppercase;
@@ -583,7 +608,7 @@ export default async function invoicesPdfRoutes(
margin-bottom: 1mm; margin-bottom: 1mm;
} }
.address-line { .address-line {
font-size: 8pt; font-size: 9pt;
color: #444; color: #444;
line-height: 1.5; line-height: 1.5;
} }
@@ -593,7 +618,7 @@ export default async function invoicesPdfRoutes(
.info-row { .info-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
font-size: 8pt; font-size: 9pt;
padding: 1mm 0; padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0; border-bottom: 0.5pt solid #f0f0f0;
} }
@@ -614,7 +639,7 @@ export default async function invoicesPdfRoutes(
/* VS/KS blok */ /* VS/KS blok */
.vs-block { .vs-block {
font-size: 8pt; font-size: 9pt;
line-height: 1.4; line-height: 1.4;
padding-top: 2mm; padding-top: 2mm;
} }
@@ -623,7 +648,7 @@ export default async function invoicesPdfRoutes(
.billing-label { .billing-label {
font-weight: 700; font-weight: 700;
color: #1a1a1a; color: #1a1a1a;
font-size: 9pt; font-size: 10pt;
padding: 2mm 0 1mm 0; padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a; border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0; margin-bottom: 0;
@@ -639,20 +664,18 @@ export default async function invoicesPdfRoutes(
margin-bottom: 2mm; margin-bottom: 2mm;
} }
table.items thead th { table.items thead th {
font-size: 8pt; font-size: 8.5pt;
font-weight: 600; font-weight: 600;
color: #646464; color: #646464;
padding: 6px 8px; padding: 4px 4px;
text-align: left; text-align: left;
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 0.5pt solid #d0d0d0; border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap; white-space: nowrap;
} }
table.items thead th.center { text-align: center; } table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; } table.items thead th.right { text-align: right; }
table.items tbody td { table.items tbody td {
padding: 5px 8px; padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0; border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle; vertical-align: middle;
color: #1a1a1a; color: #1a1a1a;
@@ -663,10 +686,10 @@ export default async function invoicesPdfRoutes(
table.items tbody td.row-num { table.items tbody td.row-num {
text-align: center; text-align: center;
color: #969696; color: #969696;
font-size: 8pt; font-size: 9pt;
} }
table.items tbody td.desc { table.items tbody td.desc {
font-size: 8pt; font-size: 9pt;
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
} }
@@ -688,7 +711,7 @@ export default async function invoicesPdfRoutes(
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
font-size: 8.5pt; font-size: 9.5pt;
color: #1a1a1a; color: #1a1a1a;
margin-bottom: 2mm; margin-bottom: 2mm;
} }
@@ -700,7 +723,7 @@ export default async function invoicesPdfRoutes(
align-items: baseline; align-items: baseline;
} }
.totals .grand .label { .totals .grand .label {
font-size: 9.5pt; font-size: 10.5pt;
font-weight: 400; font-weight: 400;
color: #1a1a1a; color: #1a1a1a;
align-self: center; align-self: center;
@@ -714,14 +737,14 @@ export default async function invoicesPdfRoutes(
} }
.totals .currency-note { .totals .currency-note {
text-align: right; text-align: right;
font-size: 7.5pt; font-size: 8pt;
color: #1a1a1a; color: #1a1a1a;
margin-top: 2mm; margin-top: 2mm;
} }
/* Vystavil */ /* Vystavil */
.issued-by { .issued-by {
font-size: 8pt; font-size: 9pt;
margin: 2mm 0; margin: 2mm 0;
line-height: 1.4; line-height: 1.4;
} }
@@ -729,7 +752,7 @@ export default async function invoicesPdfRoutes(
/* Upozorneni */ /* Upozorneni */
.notice { .notice {
font-size: 7pt; font-size: 8pt;
color: #1a1a1a; color: #1a1a1a;
margin: 2mm 0; margin: 2mm 0;
line-height: 1.3; line-height: 1.3;
@@ -751,11 +774,11 @@ export default async function invoicesPdfRoutes(
.recap-section table { .recap-section table {
border-collapse: collapse; border-collapse: collapse;
font-size: 8pt; font-size: 9pt;
flex: 1; flex: 1;
} }
.recap-section table th { .recap-section table th {
font-size: 7.5pt; font-size: 8pt;
font-weight: 600; font-weight: 600;
color: #555; color: #555;
padding: 3px 6px; padding: 3px 6px;
@@ -769,7 +792,7 @@ export default async function invoicesPdfRoutes(
} }
.recap-section table td.center { text-align: center; } .recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate { .recap-section table td.cnb-rate {
font-size: 7pt; font-size: 8pt;
color: #888; color: #888;
text-align: right; text-align: right;
border-bottom: none; border-bottom: none;
@@ -779,13 +802,14 @@ export default async function invoicesPdfRoutes(
/* Prevzal / razitko */ /* Prevzal / razitko */
.footer-row { .footer-row {
display: flex; display: flex;
justify-content: space-between;
margin-top: 4mm; margin-top: 4mm;
font-size: 8pt; font-size: 9pt;
}
.footer-row .col {
flex: 1;
border-top: 0.5pt solid #aaa; border-top: 0.5pt solid #aaa;
padding-top: 2mm; padding-top: 2mm;
min-height: 15mm;
}
.footer-row .col {
font-weight: 600; font-weight: 600;
color: #555; color: #555;
} }
@@ -793,13 +817,13 @@ export default async function invoicesPdfRoutes(
/* Poznamky */ /* Poznamky */
.invoice-notes { .invoice-notes {
margin-top: 4mm; margin-top: 4mm;
font-size: 9pt; font-size: 10pt;
line-height: 1.5; line-height: 1.5;
color: #1a1a1a; color: #1a1a1a;
} }
.invoice-notes-label { .invoice-notes-label {
font-weight: 600; font-weight: 600;
font-size: 8pt; font-size: 9pt;
text-transform: uppercase; text-transform: uppercase;
color: #555; color: #555;
margin-bottom: 1mm; margin-bottom: 1mm;
@@ -807,20 +831,18 @@ export default async function invoicesPdfRoutes(
.invoice-notes-content p { margin: 0 0 0.4em 0; } .invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } .invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; } .invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty */ /* Quill fonty v PDF vynuceno Tahoma */
.ql-font-arial { font-family: Arial, sans-serif; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
.ql-font-courier-new { font-family: "Courier New", monospace; }
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
.ql-font-garamond { font-family: Garamond, serif; }
.ql-align-center { text-align: center; } .ql-align-center { text-align: center; }
.ql-align-right { text-align: right; } .ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; } .ql-align-justify { text-align: justify; }
@@ -888,8 +910,7 @@ ${indentCSS}
<div class="info-row"><span class="lbl">${escapeHtml(t.account_no)}</span> <span class="val">${escapeHtml(invoice.bank_account)}</span></div> <div class="info-row"><span class="lbl">${escapeHtml(t.account_no)}</span> <span class="val">${escapeHtml(invoice.bank_account)}</span></div>
<div class="vs-block"> <div class="vs-block">
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong> ${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
&nbsp;&nbsp;&nbsp; ${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong><br> &nbsp;&nbsp;&nbsp; ${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong>
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ""}
</div> </div>
</td> </td>
<td> <td>
@@ -897,6 +918,8 @@ ${indentCSS}
<div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div> <div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div>
<div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div> <div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div>
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(invoice.payment_method)}</span></div> <div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(invoice.payment_method)}</span></div>
${orderNumber ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka č.:" : "Order no.:"}</span> <span class="val">${orderNumber}</span></div>` : ""}
${orderDate ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka ze dne:" : "Order date:"}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
</td> </td>
</tr> </tr>
</table> </table>
@@ -906,13 +929,13 @@ ${indentCSS}
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th class="center" style="width:4%">${escapeHtml(t.col_no)}</th> <th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:28%">${escapeHtml(t.col_desc)}</th> <th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:12%">${escapeHtml(t.col_qty)}</th> <th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:11%">${escapeHtml(t.col_unit_price)}</th> <th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:11%">${escapeHtml(t.col_price)}</th> <th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:7%">${escapeHtml(t.col_vat_pct)}</th> <th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:11%">${escapeHtml(t.col_vat)}</th> <th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th> <th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
</tr> </tr>
</thead> </thead>
@@ -1000,32 +1023,36 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
const saveMode = query.save === "1";
// Save PDF to NAS // Save PDF to NAS
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) { if (
saveMode &&
nasFinancialsManager.isConfigured() &&
invoice.invoice_number
) {
const issueDate = invoice.issue_date const issueDate = invoice.issue_date
? new Date(invoice.issue_date) ? new Date(invoice.issue_date)
: new Date(); : new Date();
const saveMode = query.save === "1"; nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfPromise = htmlToPdf(html) const pdfBuffer = await htmlToPdf(html);
.then((pdfBuffer) => {
nasFinancialsManager.saveIssuedInvoicePdf( nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!, invoice.invoice_number!,
issueDate.getFullYear(), issueDate.getFullYear(),
issueDate.getMonth() + 1, issueDate.getMonth() + 1,
pdfBuffer, pdfBuffer,
); );
})
.catch((err) => {
request.log.error(err, "Failed to save invoice PDF to NAS");
});
if (saveMode) {
await pdfPromise;
return reply.send({ success: true, message: "PDF uloženo" }); return reply.send({ success: true, message: "PDF uloženo" });
} }
}
return reply.type("text/html").send(html); return reply.type("text/html").send(html);
} catch (err) {
request.log.error(err, "PDF generation failed");
return reply
.status(500)
.type("text/html")
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
}
}, },
); );
} }

View File

@@ -13,6 +13,7 @@ import {
markOverdueInvoices, markOverdueInvoices,
listInvoices, listInvoices,
getNextInvoiceNumberFormatted, getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview,
getInvoiceStats, getInvoiceStats,
getOrderDataForInvoice, getOrderDataForInvoice,
getInvoice, getInvoice,
@@ -25,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
export default async function invoicesRoutes( export default async function invoicesRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
// Auto-update overdue invoices on GET requests only (matches PHP behavior) // Auto-update overdue invoices on GET requests, throttled to once per hour
let lastOverdueCheck = 0;
const OVERDUE_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
fastify.addHook("onRequest", async (request) => { fastify.addHook("onRequest", async (request) => {
if (request.method !== "GET") return; if (request.method !== "GET") return;
if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) {
lastOverdueCheck = Date.now();
await markOverdueInvoices(); await markOverdueInvoices();
}
}); });
// GET /api/admin/invoices // GET /api/admin/invoices
@@ -65,7 +72,7 @@ export default async function invoicesRoutes(
"/next-number", "/next-number",
{ preHandler: requirePermission("invoices.create") }, { preHandler: requirePermission("invoices.create") },
async (_request, reply) => { async (_request, reply) => {
const result = await getNextInvoiceNumberFormatted(); const result = await getNextInvoiceNumberPreview();
return success(reply, result); return success(reply, result);
}, },
); );
@@ -190,10 +197,8 @@ export default async function invoicesRoutes(
if (!existing) return error(reply, "Faktura nenalezena", 404); if (!existing) return error(reply, "Faktura nenalezena", 404);
// Delete PDF from NAS // Delete PDF from NAS
if (existing.invoice_number && existing.issue_date) { if (existing.invoice_number) {
const d = new Date(existing.issue_date); await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
nasFinancialsManager.deleteIssuedInvoice(relPath);
} }
await logAudit({ await logAudit({
@@ -227,12 +232,10 @@ export default async function invoicesRoutes(
const file = nasFinancialsManager.readIssuedInvoice(relPath); const file = nasFinancialsManager.readIssuedInvoice(relPath);
if (!file) return error(reply, "PDF soubor nenalezen", 404); if (!file) return error(reply, "PDF soubor nenalezen", 404);
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
return reply return reply
.type("application/pdf") .type("application/pdf")
.header( .header("Content-Disposition", `inline; filename="${safeName}.pdf"`)
"Content-Disposition",
`inline; filename="${invoice.invoice_number}.pdf"`,
)
.send(file.data); .send(file.data);
}, },
); );

View File

@@ -241,7 +241,20 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8; const totalHours = totalBusinessDays * 8;
try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Check for duplicate attendance records inside the transaction
for (const ac of attendanceCreates) {
const duplicate = await tx.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
});
if (duplicate) {
throw new Error(
"Pro zvolené datumy již existují záznamy docházky",
);
}
}
// 1. Create attendance records for each business day // 1. Create attendance records for each business day
if (attendanceCreates.length > 0) { if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates }); await tx.attendance.createMany({ data: attendanceCreates });
@@ -292,6 +305,19 @@ export default async function leaveRequestsRoutes(
}, },
}); });
}); });
} catch (e) {
if (
e instanceof Error &&
e.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return error(
reply,
"Pro zvolené datumy již existují záznamy docházky",
400,
);
}
throw e;
}
await logAudit({ await logAudit({
request, request,
@@ -331,6 +357,7 @@ export default async function leaveRequestsRoutes(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ const existing = await prisma.leave_requests.findUnique({
@@ -342,6 +369,10 @@ export default async function leaveRequestsRoutes(
return error(reply, "Lze zrušit pouze čekající žádosti", 400); return error(reply, "Lze zrušit pouze čekající žádosti", 400);
} }
if (existing.user_id !== authData.userId) {
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
}
await prisma.leave_requests.update({ await prisma.leave_requests.update({
where: { id }, where: { id },
data: { status: "cancelled" }, data: { status: "cancelled" },

View File

@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date"; import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager"; import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function formatDate(date: Date | string | null | undefined): string { function formatDate(date: Date | string | null | undefined): string {
if (!date) return ""; if (!date) return "";
@@ -73,6 +79,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags) // Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes // Merge adjacent spans with same attributes
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {
@@ -102,7 +109,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { if (raw) {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = cfData =
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
"/:id", "/:id",
{ preHandler: requirePermission("offers.view") }, { preHandler: requirePermission("offers.view") },
async (request, reply) => { async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
try { try {
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
if (title) if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`; scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content) if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`; scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>"; scopeHtml += "</div>";
} }
scopeHtml += "</div>"; scopeHtml += "</div>";
@@ -381,19 +394,8 @@ export default async function offersPdfRoutes(
img, table, pre, code { max-width: 100%; } img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes ---- */ /* ---- Quill font classes v PDF vynuceno Tahoma ---- */
.ql-font-arial { font-family: Arial, sans-serif; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
.ql-font-courier-new { font-family: "Courier New", monospace; }
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
.ql-font-garamond { font-family: Garamond, serif; }
/* ---- Quill alignment ---- */ /* ---- Quill alignment ---- */
.ql-align-center { text-align: center; } .ql-align-center { text-align: center; }
@@ -606,6 +608,15 @@ ${indentCSS}
word-break: normal; word-break: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
.section-content h2 { font-size: 18px; }
.section-content h3 { font-size: 16px; }
.section-content h4 { font-size: 15px; }
.section-content p { margin: 0 0 0.4em 0; } .section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; } .section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; } .section-content li { margin-bottom: 0.2em; }
@@ -763,29 +774,25 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
const saveMode = query.save === "1";
// Save PDF to NAS // Save PDF to NAS
if (nasOffersManager.isConfigured() && quotation.quotation_number) { if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at const created = quotation.created_at
? new Date(quotation.created_at) ? new Date(quotation.created_at)
: new Date(); : new Date();
const saveMode = query.save === "1"; const pdfBuffer = await htmlToPdf(html);
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
nasOffersManager.saveOfferPdf( nasOffersManager.saveOfferPdf(
quotation.quotation_number!, quotation.quotation_number!,
created.getFullYear(), created.getFullYear(),
pdfBuffer, pdfBuffer,
); );
})
.catch((err) => {
request.log.error(err, "Failed to save offer PDF to NAS");
});
if (saveMode) {
await pdfPromise;
return reply.send({ success: true, message: "PDF uloženo" }); return reply.send({ success: true, message: "PDF uloženo" });
} }
}
return reply.type("text/html").send(html); return reply.type("text/html").send(html);
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,902 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, error } from "../../utils/response";
import { parseBody } from "../../schemas/common";
import { z } from "zod";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const OrderPdfBodySchema = z
.object({
items: z
.array(
z.object({
description: z.string(),
quantity: z.number().min(0).finite(),
unit: z.string(),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().finite(),
}),
)
.optional(),
})
.passthrough();
/* ── Helpers ─────────────────────────────────────────────────────── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
tObj: Record<string, string>,
): AddressResult {
if (!entity) return { name: "", lines: [] };
const nameKey = isSupplier ? "company_name" : "name";
const name = String(entity[nameKey] || "");
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: "name",
CompanyName: "company_name",
Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
};
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || "", entity.postal_code || ""]
.filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id)
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
cfData.forEach((cf, i) => {
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder!.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
const translations: Record<string, Record<string, string>> = {
cs: {
title: "POTVRZENÍ PŘIJETÍ OBJEDNÁVKY",
supplier: "Dodavatel",
customer: "Odběratel",
order_no: "Číslo objednávky:",
po_no: "Číslo zakáz. objednávky:",
date: "Datum:",
payment_method: "Forma úhrady:",
billing: "Potvrzujeme Vám následující položky:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
issued_by: "Vystavil:",
received_by: "Převzal:",
stamp: "Razítko:",
ico: "IČ: ",
dic: "DIČ: ",
},
en: {
title: "ORDER CONFIRMATION",
supplier: "Supplier",
customer: "Customer",
order_no: "Order No.:",
po_no: "PO No.:",
date: "Date:",
payment_method: "Payment method:",
billing: "We confirm the following items:",
col_no: "No.",
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in",
notes: "Notes",
issued_by: "Issued by:",
received_by: "Received by:",
stamp: "Stamp:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
},
};
/* ── Route ───────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
try {
const lang = body.lang === "en" ? "en" : "cs";
const t = translations[lang];
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
const currency = order.currency || "CZK";
const applyVat =
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
// Use custom items from body if provided, otherwise order items
const customItemsRaw = body.items;
let items: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
is_included_in_total: it.is_included_in_total !== false,
vat_rate: it.vat_rate,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
vat_rate: orderVatRate,
}));
}
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
for (const item of items) {
if (item.is_included_in_total) {
const lineTotal = item.quantity * item.unit_price;
subtotal += lineTotal;
const rate = item.vat_rate;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineTotal;
if (applyVat) {
const lineVat = (lineTotal * rate) / 100;
vatSummary[key].vat += lineVat;
totalVat += lineVat;
}
}
}
const totalToPay = subtotal + totalVat;
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
const itemsHtml = items
.map((item, i) => {
const lineSubtotal = item.quantity * item.unit_price;
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(t.title)} ${orderNumber}</title>
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 186mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; }
/* ── Hlavicka ── */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1mm;
padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a;
}
.invoice-header .left {
display: flex;
align-items: center;
gap: 3mm;
}
.logo-header { text-align: left; }
.company-title {
font-size: 12pt;
font-weight: 700;
}
.invoice-title {
font-size: 13pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
letter-spacing: 0.03em;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* ── Adresy ── */
.header-grid {
border: 0.5pt solid #d0d0d0;
border-collapse: collapse;
width: 100%;
margin-bottom: 1mm;
}
.header-grid td {
padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0;
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
background: #f5f5f5;
}
.header-grid td.details-bank {
background: #f5f5f5;
}
.address-label {
font-size: 8pt;
font-weight: 700;
color: #de3a3a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1mm;
}
.address-name {
font-size: 10pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 1mm;
}
.address-line {
font-size: 9pt;
color: #444;
line-height: 1.5;
}
/* ── Detaily (banka + datumy) — inside header-grid ── */
.info-row {
display: flex;
align-items: baseline;
font-size: 9pt;
padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-row .lbl {
color: #666;
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
margin-right: 3mm;
}
.info-row .val {
font-weight: 600;
color: #1a1a1a;
text-align: right;
margin-left: auto;
}
/* VS/KS blok */
.vs-block {
font-size: 9pt;
line-height: 1.4;
padding-top: 2mm;
}
/* ── Polozky ── */
.billing-label {
font-weight: 700;
color: #1a1a1a;
font-size: 10pt;
padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8.5pt;
font-weight: 600;
color: #646464;
padding: 4px 4px;
text-align: left;
border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 9pt;
}
table.items tbody td.desc {
font-size: 9pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 9.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 10.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 8pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 8pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* DPH rekapitulace + QR */
.recap-section {
display: flex;
gap: 5mm;
align-items: flex-start;
margin-top: 1mm;
}
.recap-section .qr {
flex-shrink: 0;
width: 28mm;
}
.recap-section .qr img,
.recap-section .qr svg { width: 28mm; height: 28mm; }
.recap-section table {
border-collapse: collapse;
font-size: 9pt;
flex: 1;
}
.recap-section table th {
font-size: 8pt;
font-weight: 600;
color: #555;
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #ccc;
}
.recap-section table td {
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #eee;
}
.recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate {
font-size: 8pt;
color: #888;
text-align: right;
border-bottom: none;
padding-top: 4px;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
justify-content: space-between;
margin-top: 4mm;
font-size: 9pt;
border-top: 0.5pt solid #aaa;
padding-top: 2mm;
min-height: 15mm;
}
.footer-row .col {
font-weight: 600;
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.title)}</div>
</div>
<!-- Dodavatel / Odberatel + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</td>
<td class="addr-customer">
<div class="address-label">${escapeHtml(t.customer)}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</td>
</tr>
<tr>
<td class="details-bank">
<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>
${poNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.po_no)}</span> <span class="val">${poNumber}</span></div>` : ""}
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(paymentMethod)}</span></div>
</td>
<td>
<div class="info-row"><span class="lbl">${escapeHtml(t.date)}</span> <span class="val">${escapeHtml(orderDateStr)}</span></div>
</td>
</tr>
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<!-- Soucty -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
${notesHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(userName)}
</div>
<!-- Prevzal / razitko -->
<div class="footer-row">
<div class="col">${escapeHtml(t.received_by)}</div>
<div class="col" style="text-align:right">${escapeHtml(t.stamp)}</div>
</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</body>
</html>`;
const pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
return reply
.type("application/pdf")
.header("Content-Disposition", `attachment; filename="${filename}"`)
.send(pdfBuffer);
} catch (err) {
request.log.error(err, "PDF generation failed");
return reply
.status(500)
.type("text/html")
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
}
},
);
}

View File

@@ -1,9 +1,10 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { config } from "../../config/env";
import { import {
CreateOrderFromQuotationSchema, CreateOrderFromQuotationSchema,
CreateOrderSchema, CreateOrderSchema,
@@ -25,7 +26,9 @@ import multipart from "@fastify/multipart";
export default async function ordersRoutes( export default async function ordersRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }); await fastify.register(multipart, {
limits: { fileSize: config.nas.maxUploadSize },
});
// GET /api/admin/orders/next-number // GET /api/admin/orders/next-number
fastify.get( fastify.get(
@@ -54,15 +57,11 @@ export default async function ordersRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
}); });
return reply.send({ return paginated(
success: true, reply,
data: result.data, result.data,
pagination: buildPaginationMeta( buildPaginationMeta(result.total, result.page, result.limit),
result.total, );
result.page,
result.limit,
),
});
}, },
); );
@@ -88,12 +87,10 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id); const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404); if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply return reply
.type("application/pdf") .type("application/pdf")
.header( .header("Content-Disposition", `inline; filename="${safeFilename}"`)
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.send(attachment.data); .send(attachment.data);
}, },
); );
@@ -209,6 +206,7 @@ export default async function ordersRoutes(
const body = manualParsed.data; const body = manualParsed.data;
const result = await createOrder(body); const result = await createOrder(body);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({ await logAudit({
request, request,

View File

@@ -38,12 +38,7 @@ export default async function profileRoutes(
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (body.email) { if (body.email) {
const newEmail = String(body.email).trim(); data.email = String(body.email).trim();
const existing = await prisma.users.findFirst({
where: { email: newEmail, id: { not: userId } },
});
if (existing) return error(reply, "E-mail již existuje", 409);
data.email = newEmail;
} }
if (body.first_name) data.first_name = String(body.first_name); if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name); if (body.last_name) data.last_name = String(body.last_name);
@@ -63,18 +58,42 @@ export default async function profileRoutes(
config.security.bcryptCost, config.security.bcryptCost,
); );
data.password_changed_at = new Date(); data.password_changed_at = new Date();
}
// Wrap email uniqueness check and update in a transaction to prevent race condition
try {
await prisma.$transaction(async (tx) => {
if (data.email) {
const existing = await tx.users.findFirst({
where: { email: String(data.email), id: { not: userId } },
});
if (existing) throw new Error("EMAIL_EXISTS");
}
await tx.users.update({ where: { id: userId }, data });
});
} catch (e) {
if (e instanceof Error && e.message === "EMAIL_EXISTS") {
return error(reply, "E-mail již existuje", 409);
}
throw e;
}
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: "password_change", action: "update",
entityType: "user", entityType: "user",
entityId: userId, entityId: userId,
description: "Změna hesla", description: data.password_hash ? "Změna hesla" : "Aktualizace profilu",
});
if (body.current_password && body.new_password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: userId, replaced_at: null },
data: { replaced_at: new Date() },
}); });
} }
await prisma.users.update({ where: { id: userId }, data });
return success(reply, null, 200, "Profil aktualizován"); return success(reply, null, 200, "Profil aktualizován");
}); });
} }

View File

@@ -1,6 +1,7 @@
import fs from "fs"; import fs from "fs";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
import { z } from "zod";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { config } from "../../config/env"; import { config } from "../../config/env";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import { NasFileManager } from "../../services/nas-file-manager"; import { NasFileManager } from "../../services/nas-file-manager";
const ProjectFilesQuerySchema = z.object({
project_id: z.string().min(1, "project_id je povinný"),
path: z.string().optional(),
action: z.string().optional(),
});
function parseProjectFilesQuery(
query: unknown,
):
| { data: { project_id: string; path?: string; action?: string } }
| { error: string } {
try {
const data = ProjectFilesQuerySchema.parse(query);
return { data };
} catch (e) {
if (e instanceof z.ZodError) {
return { error: e.issues.map((err) => err.message).join(", ") };
}
return { error: "Neplatné parametry dotazu" };
}
}
export default async function projectFilesRoutes( export default async function projectFilesRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.view") }, { preHandler: requirePermission("projects.view") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const { project_id: projectIdStr, path: subPath = "" } = parsedQuery.data;
const projectId = Number(projectIdStr);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
@@ -39,9 +64,7 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const subPath = query.path || ""; if (parsedQuery.data.action === "download") {
if (query.action === "download") {
if (!subPath) return error(reply, "Cesta k souboru je povinná"); if (!subPath) return error(reply, "Cesta k souboru je povinná");
if (!project.project_number) if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu"); return error(reply, "Projekt nemá číslo projektu");
@@ -50,10 +73,11 @@ export default async function projectFilesRoutes(
if (!result) return error(reply, "Soubor nebyl nalezen", 404); if (!result) return error(reply, "Soubor nebyl nalezen", 404);
const stream = fs.createReadStream(result.filePath); const stream = fs.createReadStream(result.filePath);
const encodedName = encodeURIComponent(result.fileName);
return reply return reply
.header( .header(
"Content-Disposition", "Content-Disposition",
`attachment; filename="${encodeURIComponent(result.fileName)}"`, `attachment; filename*=UTF-8''${encodedName}`,
) )
.header("Content-Type", result.mime) .header("Content-Type", result.mime)
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
@@ -81,8 +105,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -105,7 +130,11 @@ export default async function projectFilesRoutes(
fm.createProjectFolder(project.project_number, project.name || ""); fm.createProjectFolder(project.project_number, project.name || "");
} }
const err = fm.createFolder(project.project_number, path, folderName); const err = await fm.createFolder(
project.project_number,
path,
folderName,
);
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
await logAudit({ await logAudit({
@@ -130,8 +159,9 @@ export default async function projectFilesRoutes(
bodyLimit: config.nas.maxUploadSize, bodyLimit: config.nas.maxUploadSize,
}, },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -149,14 +179,14 @@ export default async function projectFilesRoutes(
const file = await request.file(); const file = await request.file();
if (!file) return error(reply, "Nebyl nahrán žádný soubor"); if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = query.path || ""; const subPath = parsedQuery.data.path || "";
const fileBuffer = await file.toBuffer(); const rawFileName = file.filename;
const fileName = file.filename; const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,
subPath, subPath,
fileBuffer, file.file,
fileName, fileName,
); );
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
@@ -180,8 +210,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -198,7 +229,7 @@ export default async function projectFilesRoutes(
if (!fromPath || !toPath) if (!fromPath || !toPath)
return error(reply, "Zdrojová i cílová cesta jsou povinné"); return error(reply, "Zdrojová i cílová cesta jsou povinné");
const err = fm.moveItem(project.project_number, fromPath, toPath); const err = await fm.moveItem(project.project_number, fromPath, toPath);
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
await logAudit({ await logAudit({
@@ -221,8 +252,9 @@ export default async function projectFilesRoutes(
"/", "/",
{ preHandler: requirePermission("projects.files") }, { preHandler: requirePermission("projects.files") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, string>; const parsedQuery = parseProjectFilesQuery(request.query);
const projectId = Number(query.project_id); if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) if (!project.project_number)
@@ -232,7 +264,7 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const filePath = query.path || ""; const filePath = parsedQuery.data.path || "";
if (!filePath) return error(reply, "Cesta k souboru je povinná"); if (!filePath) return error(reply, "Cesta k souboru je povinná");
const err = await fm.deleteItem(project.project_number, filePath); const err = await fm.deleteItem(project.project_number, filePath);

View File

@@ -5,19 +5,16 @@ import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
CreateProjectSchema,
UpdateProjectSchema, UpdateProjectSchema,
CreateProjectNoteSchema, CreateProjectNoteSchema,
} from "../../schemas/projects.schema"; } from "../../schemas/projects.schema";
import { import {
listProjects, listProjects,
getProject, getProject,
createProject,
updateProject, updateProject,
deleteProject, deleteProject,
createProjectNote, createProjectNote,
deleteProjectNote, deleteProjectNote,
getNextProjectNumber,
} from "../../services/projects.service"; } from "../../services/projects.service";
export default async function projectsRoutes( export default async function projectsRoutes(
@@ -61,27 +58,6 @@ export default async function projectsRoutes(
}, },
); );
fastify.post(
"/",
{ preHandler: requirePermission("projects.create") },
async (request, reply) => {
const parsed = parseBody(CreateProjectSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const project = await createProject(parsed.data);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "project",
entityId: project.id,
description: `Vytvořen projekt ${project.name}`,
});
return success(reply, { id: project.id }, 201, "Projekt byl vytvořen");
},
);
fastify.put<{ Params: { id: string } }>( fastify.put<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requirePermission("projects.edit") }, { preHandler: requirePermission("projects.edit") },
@@ -91,8 +67,11 @@ export default async function projectsRoutes(
const parsed = parseBody(UpdateProjectSchema, request.body); const parsed = parseBody(UpdateProjectSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const existing = await updateProject(id, parsed.data); const result = await updateProject(id, parsed.data);
if (!existing) return error(reply, "Projekt nenalezen", 404); if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) {
return error(reply, result.error, (result as any).status ?? 400);
}
await logAudit({ await logAudit({
request, request,
@@ -100,7 +79,7 @@ export default async function projectsRoutes(
action: "update", action: "update",
entityType: "project", entityType: "project",
entityId: id, entityId: id,
description: `Upraven projekt ${existing.name}`, description: `Upraven projekt ${result.name}`,
}); });
return success(reply, { id }, 200, "Projekt byl uložen"); return success(reply, { id }, 200, "Projekt byl uložen");
}, },
@@ -123,21 +102,14 @@ export default async function projectsRoutes(
lastName: authData.lastName, lastName: authData.lastName,
content: parsed.data.content ?? undefined, content: parsed.data.content ?? undefined,
}); });
if (note && "error" in note) {
return error(reply, note.error, (note as any).status ?? 400);
}
return success(reply, { note }, 201, "Poznámka byla přidána"); return success(reply, { note }, 201, "Poznámka byla přidána");
}, },
); );
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
fastify.get(
"/next-number",
{ preHandler: requirePermission("projects.create") },
async (_request, reply) => {
const nextNumber = await getNextProjectNumber();
return success(reply, { next_number: nextNumber });
},
);
// DELETE /api/admin/projects/:id/notes/:noteId // DELETE /api/admin/projects/:id/notes/:noteId
fastify.delete<{ Params: { id: string; noteId: string } }>( fastify.delete<{ Params: { id: string; noteId: string } }>(
"/:id/notes/:noteId", "/:id/notes/:noteId",

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
@@ -44,11 +44,11 @@ export default async function quotationsRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
}); });
return reply.send({ return paginated(
success: true, reply,
data: result.data, result.data,
pagination: buildPaginationMeta(result.total, page, limit), buildPaginationMeta(result.total, page, limit),
}); );
}, },
); );
@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data); const quotation = await createOffer(parsed.data);
if ("error" in quotation)
return error(reply, quotation.error!, quotation.status!);
await logAudit({ await logAudit({
request, request,
@@ -278,7 +280,11 @@ export default async function quotationsRoutes(
return error(reply, "Nabídka nenalezena", 404); return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalidated") if (result.error === "invalidated")
return error(reply, "Nelze upravit zneplatněnou nabídku", 400); return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
return error(reply, "Neznámá chyba", 500); return error(
reply,
result.error || "Neznámá chyba",
(result as any).status ?? 400,
);
} }
// Keep lock — user stays on the page after save // Keep lock — user stays on the page after save
@@ -308,9 +314,13 @@ export default async function quotationsRoutes(
// Delete PDF from NAS // Delete PDF from NAS
if (existing.quotation_number && existing.created_at) { if (existing.quotation_number && existing.created_at) {
const yr = new Date(existing.created_at).getFullYear(); const yr = new Date(existing.created_at).getFullYear();
nasOffersManager.deleteOfferPdf( try {
await nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr), nasOffersManager.buildRelativePath(existing.quotation_number, yr),
); );
} catch {
// Non-fatal: NAS delete may fail if file does not exist
}
} }
await logAudit({ await logAudit({

View File

@@ -13,8 +13,14 @@ import {
} from "../../schemas/received-invoices.schema"; } from "../../schemas/received-invoices.schema";
import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates"; import { toCzk } from "../../services/exchange-rates";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const; const VALID_STATUSES = ["unpaid", "paid"] as const;
/** Round a monetary value to 2 decimal places to avoid floating-point drift. */
function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}
const ALLOWED_SORT_FIELDS = [ const ALLOWED_SORT_FIELDS = [
"id", "id",
"supplier_name", "supplier_name",
@@ -93,7 +99,7 @@ export default async function receivedInvoicesRoutes(
// Aggregate by currency → CurrencyAmount[] format // Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = ( const aggregateByCurrency = (
invs: typeof monthInvoices, invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount", field: "amount" | "vat_amount",
) => { ) => {
const map: Record<string, number> = {}; const map: Record<string, number> = {};
@@ -110,7 +116,7 @@ export default async function receivedInvoicesRoutes(
}; };
const sumCzk = async ( const sumCzk = async (
invs: typeof monthInvoices, invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount", field: "amount" | "vat_amount",
) => { ) => {
let total = 0; let total = 0;
@@ -121,9 +127,13 @@ export default async function receivedInvoicesRoutes(
return Math.round(total * 100) / 100; return Math.round(total * 100) / 100;
}; };
// Also get all-time unpaid // All-time unpaid invoices (no soft-delete column, so just filter by status)
const unpaidCount = await prisma.received_invoices.count({
where: { status: { not: "paid" } },
});
const allUnpaid = await prisma.received_invoices.findMany({ const allUnpaid = await prisma.received_invoices.findMany({
where: { status: { not: "paid" } }, where: { status: { not: "paid" } },
select: { amount: true, currency: true },
}); });
return success(reply, { return success(reply, {
@@ -133,7 +143,7 @@ export default async function receivedInvoicesRoutes(
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"), vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"), unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"), unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: allUnpaid.length, unpaid_count: unpaidCount,
month_count: monthInvoices.length, month_count: monthInvoices.length,
}); });
}, },
@@ -183,12 +193,10 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf"; const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
return reply return reply
.type(mime) .type(mime)
.header( .header("Content-Disposition", `inline; filename="${safeFileName}"`)
"Content-Disposition",
`inline; filename="${invoice.file_name}"`,
)
.send(nasFile.data); .send(nasFile.data);
}, },
); );
@@ -258,10 +266,10 @@ export default async function receivedInvoicesRoutes(
const meta = invoicesMeta[i] || {}; const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0); const amount = Number(meta.amount ?? 0);
const vatRate = Number(meta.vat_rate ?? 21); const vatRate = Number(meta.vat_rate ?? 21);
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100) // Amount is net — VAT = amount * rate / 100
const vatAmount = const vatAmount =
vatRate > 0 vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 ? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0; : 0;
const issueDate = meta.issue_date const issueDate = meta.issue_date
@@ -310,7 +318,9 @@ export default async function receivedInvoicesRoutes(
status: "unpaid", status: "unpaid",
notes: meta.notes ? String(meta.notes) : null, notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId, uploaded_by: request.authData?.userId,
file_name: file.name, file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime, file_mime: file.mime,
file_size: file.size, file_size: file.size,
}, },
@@ -359,7 +369,7 @@ export default async function receivedInvoicesRoutes(
vat_rate: vatRate, vat_rate: vatRate,
vat_amount: vat_amount:
vatRate > 0 vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 ? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0, : 0,
issue_date: body.issue_date issue_date: body.issue_date
? new Date(String(body.issue_date)) ? new Date(String(body.issue_date))
@@ -411,6 +421,15 @@ export default async function receivedInvoicesRoutes(
} }
} }
if (String(existing.status) === "paid") {
const attempted = Object.keys(body).filter(
(k) => !["status", "paid_date", "notes"].includes(k),
);
if (attempted.length > 0) {
return error(reply, "Nelze upravit uhrazenou fakturu", 400);
}
}
// Recalculate vat_amount when amount or vat_rate changes (matching PHP) // Recalculate vat_amount when amount or vat_rate changes (matching PHP)
const finalAmount = const finalAmount =
body.amount !== undefined body.amount !== undefined
@@ -420,13 +439,9 @@ export default async function receivedInvoicesRoutes(
body.vat_rate !== undefined body.vat_rate !== undefined
? Number(body.vat_rate) ? Number(body.vat_rate)
: Number(existing.vat_rate); : Number(existing.vat_rate);
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100) // Amount is net — VAT = amount * rate / 100
const computedVat = const computedVat =
finalVatRate > 0 finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0;
? Math.round(
(finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100,
) / 100
: 0;
// Auto-set paid_date when status transitions to paid (matching PHP) // Auto-set paid_date when status transitions to paid (matching PHP)
const newStatus = const newStatus =
@@ -534,6 +549,9 @@ export default async function receivedInvoicesRoutes(
}); });
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404); if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
await prisma.received_invoices.delete({ where: { id } });
if (existing.file_name) { if (existing.file_name) {
const relPath = nasFinancialsManager.buildReceivedPath( const relPath = nasFinancialsManager.buildReceivedPath(
existing.file_name, existing.file_name,
@@ -542,8 +560,6 @@ export default async function receivedInvoicesRoutes(
); );
nasFinancialsManager.deleteReceivedInvoice(relPath); nasFinancialsManager.deleteReceivedInvoice(relPath);
} }
await prisma.received_invoices.delete({ where: { id } });
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,

View File

@@ -62,12 +62,16 @@ export default async function rolesRoutes(
}); });
if (Array.isArray(body.permission_ids)) { if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.createMany({ await prisma.$transaction(
data: (body.permission_ids as number[]).map((pid) => ({ (body.permission_ids as number[]).map((pid) =>
prisma.role_permissions.create({
data: {
role_id: role.id, role_id: role.id,
permission_id: pid, permission_id: pid,
})), },
}); }),
),
);
} }
await logAudit({ await logAudit({
@@ -111,13 +115,15 @@ export default async function rolesRoutes(
}); });
if (Array.isArray(body.permission_ids)) { if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.deleteMany({ where: { role_id: id } }); await prisma.$transaction([
await prisma.role_permissions.createMany({ prisma.role_permissions.deleteMany({ where: { role_id: id } }),
prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({ data: (body.permission_ids as number[]).map((pid) => ({
role_id: id, role_id: id,
permission_id: pid, permission_id: pid,
})), })),
}); }),
]);
} }
await logAudit({ await logAudit({

View File

@@ -70,12 +70,12 @@ export default async function scopeTemplatesRoutes(
}; };
if (body.id) { if (body.id) {
const existingItem = await prisma.item_templates.findUnique({ const existingItem = await prisma.item_templates.findFirst({
where: { id: Number(body.id) }, where: { id: Number(body.id), is_deleted: false },
}); });
if (!existingItem) return error(reply, "Šablona nenalezena", 404); if (!existingItem) return error(reply, "Šablona nenalezena", 404);
await prisma.item_templates.update({ await prisma.item_templates.updateMany({
where: { id: Number(body.id) }, where: { id: Number(body.id), is_deleted: false },
data: { ...itemData, modified_at: new Date() }, data: { ...itemData, modified_at: new Date() },
}); });
return success( return success(
@@ -188,10 +188,11 @@ export default async function scopeTemplatesRoutes(
}); });
if (Array.isArray(body.sections)) { if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.deleteMany({ await prisma.$transaction(async (tx) => {
await tx.scope_template_sections.deleteMany({
where: { scope_template_id: id }, where: { scope_template_id: id },
}); });
await prisma.scope_template_sections.createMany({ await tx.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id, scope_template_id: id,
title: s.title ?? null, title: s.title ?? null,
@@ -200,6 +201,7 @@ export default async function scopeTemplatesRoutes(
position: s.position ?? i, position: s.position ?? i,
})), })),
}); });
});
} }
return success(reply, { id }, 200, "Šablona byla uložena"); return success(reply, { id }, 200, "Šablona byla uložena");

View File

@@ -1,12 +1,8 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import crypto from "crypto";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth"; import { requireAuth } from "../../middleware/auth";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import { hashToken } from "../../services/auth";
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/** Parse user-agent string into browser, OS, and device icon */ /** Parse user-agent string into browser, OS, and device icon */
function parseUserAgent(ua: string | null): { function parseUserAgent(ua: string | null): {
@@ -86,6 +82,7 @@ export default async function sessionsRoutes(
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
if (Number.isNaN(id)) return error(reply, "Neplatné ID relace", 400);
const authData = request.authData!; const authData = request.authData!;
const session = await prisma.refresh_tokens.findFirst({ const session = await prisma.refresh_tokens.findFirst({
@@ -111,11 +108,15 @@ export default async function sessionsRoutes(
const currentToken = request.cookies?.refresh_token; const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null; const currentHash = currentToken ? hashToken(currentToken) : null;
if (!currentHash) {
return error(reply, "Nelze identifikovat aktuální relaci", 400);
}
await prisma.refresh_tokens.updateMany({ await prisma.refresh_tokens.updateMany({
where: { where: {
user_id: authData.userId, user_id: authData.userId,
replaced_at: null, replaced_at: null,
...(currentHash ? { token_hash: { not: currentHash } } : {}), token_hash: { not: currentHash },
}, },
data: { replaced_at: new Date() }, data: { replaced_at: new Date() },
}); });

View File

@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import { encrypt } from "../../utils/encryption"; import { encrypt } from "../../utils/encryption";
import { getSystemSettings } from "../../services/system-settings"; import { getSystemSettings } from "../../services/system-settings";
import { config } from "../../config/env";
import { OTPAuth } from "../../utils/totp"; import { OTPAuth } from "../../utils/totp";
import * as OTPAuthLib from "otpauth"; import * as OTPAuthLib from "otpauth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { TotpBackupSchema } from "../../schemas/auth.schema"; import {
TotpBackupSchema,
TotpEnableSchema,
TotpDisableSchema,
TotpRequiredSchema,
} from "../../schemas/auth.schema";
export default async function totpRoutes( export default async function totpRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -47,21 +53,45 @@ export default async function totpRoutes(
"/enable", "/enable",
{ preHandler: requireAuth, bodyLimit: 10240 }, { preHandler: requireAuth, bodyLimit: 10240 },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpEnableSchema, request.body);
const { secret, code } = body; if ("error" in parsed) return error(reply, parsed.error, 400);
const { secret, code, password, current_code } = parsed.data;
if (!secret || !code) { const user = await prisma.users.findUnique({
return error(reply, "Secret a kód jsou povinné", 400); where: { id: request.authData!.userId },
});
if (!user) return error(reply, "Uživatel nenalezen", 404);
if (user.totp_enabled) {
if (!current_code) {
return error(
reply,
"Aktuální TOTP kód je povinný pro změnu 2FA",
400,
);
}
const verifyResult = OTPAuth.verify(user.totp_secret!, current_code);
if (!verifyResult.valid) {
return error(reply, "Neplatný aktuální TOTP kód", 400);
}
} else {
if (!password) {
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return error(reply, "Nesprávné heslo", 400);
}
} }
const totp = new OTPAuthLib.TOTP({ const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)), secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1", algorithm: "SHA1",
digits: 6, digits: 6,
period: 30, period: 30,
}); });
const delta = totp.validate({ token: String(code), window: 1 }); const delta = totp.validate({ token: code, window: 1 });
if (delta === null) { if (delta === null) {
return error(reply, "Neplatný TOTP kód", 400); return error(reply, "Neplatný TOTP kód", 400);
} }
@@ -70,9 +100,11 @@ export default async function totpRoutes(
const backupCodesPlain: string[] = []; const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = []; const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase(); const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(code); backupCodesPlain.push(plainCode);
backupCodesHashed.push(bcrypt.hashSync(code, 10)); backupCodesHashed.push(
await bcrypt.hash(plainCode, config.security.bcryptCost),
);
} }
const encryptedSecret = encrypt(String(secret)); const encryptedSecret = encrypt(String(secret));
@@ -107,11 +139,9 @@ export default async function totpRoutes(
"/disable", "/disable",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpDisableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
if (!body.code) { const { code } = parsed.data;
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
}
const user = await prisma.users.findUnique({ const user = await prisma.users.findUnique({
where: { id: request.authData!.userId }, where: { id: request.authData!.userId },
@@ -120,8 +150,8 @@ export default async function totpRoutes(
return error(reply, "2FA není aktivní", 400); return error(reply, "2FA není aktivní", 400);
} }
const isValid = OTPAuth.verify(user.totp_secret, String(body.code)); const verifyResult = OTPAuth.verify(user.totp_secret, code);
if (!isValid) { if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 400); return error(reply, "Neplatný TOTP kód", 400);
} }
@@ -181,14 +211,29 @@ export default async function totpRoutes(
bodyLimit: 10240, bodyLimit: 10240,
}, },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpRequiredSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const required = parsed.data.required;
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
const oldValue = settings?.require_2fa ?? false;
const required =
body.required === true || body.required === 1 || body.required === "1";
await prisma.company_settings.updateMany({ await prisma.company_settings.updateMany({
data: { require_2fa: required }, data: { require_2fa: required },
}); });
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "company_settings",
description: `Povinné 2FA změněno z ${oldValue ? "zapnuto" : "vypnuto"} na ${required ? "zapnuto" : "vypnuto"}`,
oldValues: { require_2fa: oldValue },
newValues: { require_2fa: required },
});
const message = required const message = required
? "2FA je nyní povinné pro všechny uživatele" ? "2FA je nyní povinné pro všechny uživatele"
: "2FA již není povinné"; : "2FA již není povinné";
@@ -200,7 +245,15 @@ export default async function totpRoutes(
// POST - verify backup code (pre-auth, no requireAuth) // POST - verify backup code (pre-auth, no requireAuth)
fastify.post( fastify.post(
"/backup-verify", "/backup-verify",
{ bodyLimit: 10240 }, {
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body); const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -211,21 +264,39 @@ export default async function totpRoutes(
.update(login_token) .update(login_token)
.digest("hex"); .digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const settings = await getSystemSettings();
where: { token_hash: tokenHash },
}); const txResult = await prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) { if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, "Neplatný nebo expirovaný login token", 401); return { error: "Neplatný nebo expirovaný login token", status: 401 };
} }
const user = await prisma.users.findUnique({ // $queryRaw on MySQL may return BigInt for integer columns
where: { id: storedToken.user_id }, const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id);
const user = await tx.users.findUnique({
where: { id: storedUserId },
include: { roles: true }, include: { roles: true },
}); });
if (!user || !user.totp_backup_codes) { if (!user || !user.totp_backup_codes) {
return error(reply, "Uživatel nenalezen", 401); return { error: "Uživatel nenalezen", status: 401 };
}
if (!user.is_active) {
return { error: "Účet je deaktivován", status: 401 };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 };
} }
const backupCodes: string[] = JSON.parse( const backupCodes: string[] = JSON.parse(
@@ -237,16 +308,39 @@ export default async function totpRoutes(
const isMatch = await bcrypt.compare(String(code), backupCodes[i]); const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) { if (isMatch) {
matchIndex = i; matchIndex = i;
break;
} }
} }
if (matchIndex === -1) { if (matchIndex === -1) {
return error(reply, "Neplatný záložní kód", 401); const newFailedAttempts = (user.failed_login_attempts ?? 0) + 1;
if (newFailedAttempts >= settings.max_login_attempts) {
await tx.totp_login_tokens.delete({
where: { id: storedTokenId },
});
await tx.users.update({
where: { id: user.id },
data: {
failed_login_attempts: newFailedAttempts,
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
return { error: "Účet je dočasně uzamčen", status: 429 };
}
await tx.users.update({
where: { id: user.id },
data: { failed_login_attempts: newFailedAttempts },
});
return { error: "Neplatný záložní kód", status: 401 };
} }
await tx.totp_login_tokens.delete({
where: { id: Number(storedToken.id) },
});
backupCodes.splice(matchIndex, 1); backupCodes.splice(matchIndex, 1);
await prisma.users.update({ await tx.users.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
totp_backup_codes: JSON.stringify(backupCodes), totp_backup_codes: JSON.stringify(backupCodes),
@@ -256,7 +350,14 @@ export default async function totpRoutes(
}, },
}); });
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } }); return { user };
});
if ("error" in txResult) {
return error(reply, txResult.error!, txResult.status!);
}
const user = txResult.user;
// Create tokens (same as /login/totp flow) // Create tokens (same as /login/totp flow)
const { loadAuthData } = await import("../../services/auth"); const { loadAuthData } = await import("../../services/auth");
@@ -305,6 +406,14 @@ export default async function totpRoutes(
maxAge: config.jwt.refreshTokenSessionExpiry, maxAge: config.jwt.refreshTokenSessionExpiry,
}); });
await logAudit({
request,
action: "login_backup",
entityType: "user",
entityId: user.id,
description: `Backup code login for user ${user.username}`,
});
return success(reply, { access_token: accessToken, user: authData }); return success(reply, { access_token: accessToken, user: authData });
}, },
); );

View File

@@ -38,9 +38,15 @@ export default async function tripsRoutes(
mo = NaN; mo = NaN;
} }
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) { if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = { where.trip_date = {
gte: new Date(yr, mo - 1, 1), gte: new Date(monthStart),
lt: new Date(yr, mo, 1), lt: new Date(nextMonth),
}; };
} }
} }
@@ -75,17 +81,10 @@ export default async function tripsRoutes(
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "trips.record" } }, some: { permissions: { name: "trips.record" } },
}, },
}, },
],
},
},
}, },
select: { select: {
id: true, id: true,
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
if (filterUserId) where.user_id = filterUserId; if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId; if (filterVehicleId) where.vehicle_id = filterVehicleId;
if (query.month && query.year) { if (query.month && query.year) {
// Use explicit date strings to avoid toJSON timezone shift
const yr = Number(query.year);
const mo = Number(query.month);
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = { where.trip_date = {
gte: new Date(Number(query.year), Number(query.month) - 1, 1), gte: new Date(monthStart),
lt: new Date(Number(query.year), Number(query.month), 1), lt: new Date(nextMonth),
}; };
} }
@@ -175,7 +182,7 @@ export default async function tripsRoutes(
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0) // Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>( fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId", "/last-km/:vehicleId",
{ preHandler: requireAuth }, { preHandler: requirePermission("trips.view") },
async (request, reply) => { async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10); const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400); if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
@@ -206,6 +213,10 @@ export default async function tripsRoutes(
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; const authData = request.authData!;
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
const trip = await prisma.trips.create({ const trip = await prisma.trips.create({
data: { data: {
vehicle_id: Number(body.vehicle_id), vehicle_id: Number(body.vehicle_id),
@@ -247,6 +258,18 @@ export default async function tripsRoutes(
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; const authData = request.authData!;
if (
body.end_km != null &&
body.start_km != null &&
body.end_km < body.start_km
) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
const existing = await prisma.trips.findUnique({ where: { id } }); const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, "Jízda nenalezena", 404); if (!existing) return error(reply, "Jízda nenalezena", 404);

View File

@@ -1,7 +1,8 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema"; import { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema";
@@ -24,15 +25,11 @@ export default async function usersRoutes(
const params = parsePagination(request.query as Record<string, unknown>); const params = parsePagination(request.query as Record<string, unknown>);
const result = await listUsers(params); const result = await listUsers(params);
return reply.send({ return paginated(
success: true, reply,
data: result.users, result.users,
pagination: buildPaginationMeta( buildPaginationMeta(result.total, result.page, result.limit),
result.total, );
result.page,
result.limit,
),
});
}, },
); );
@@ -59,7 +56,8 @@ export default async function usersRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const result = await createUser({ const result = await createUser(
{
username: body.username, username: body.username,
email: body.email, email: body.email,
password: body.password, password: body.password,
@@ -67,7 +65,9 @@ export default async function usersRoutes(
last_name: body.last_name, last_name: body.last_name,
role_id: body.role_id, role_id: body.role_id,
is_active: body.is_active, is_active: body.is_active,
}); },
request.authData?.roleName ?? undefined,
);
if ("error" in result) return error(reply, result.error!, result.status!); if ("error" in result) return error(reply, result.error!, result.status!);
@@ -106,9 +106,20 @@ export default async function usersRoutes(
? Number(parsed.data.role_id) ? Number(parsed.data.role_id)
: (parsed.data.role_id as number | null | undefined), : (parsed.data.role_id as number | null | undefined),
}; };
const result = await updateUser(id, userData); const result = await updateUser(
id,
userData,
request.authData?.roleName ?? undefined,
);
if ("error" in result) return error(reply, result.error!, result.status!); if ("error" in result) return error(reply, result.error!, result.status!);
if (parsed.data.password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: id, replaced_at: null },
data: { replaced_at: new Date() },
});
}
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,

View File

@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
const existing = await prisma.vehicles.findUnique({ where: { id } }); const existing = await prisma.vehicles.findUnique({ where: { id } });
if (!existing) return error(reply, "Vozidlo nenalezeno", 404); if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
// Check for linked trips before deleting
const tripCount = await prisma.trips.count({
where: { vehicle_id: id },
});
if (tripCount > 0) {
return error(
reply,
"Vozidlo má přiřazené jízdy a nelze jej smazat",
409,
);
}
await prisma.vehicles.delete({ where: { id } }); await prisma.vehicles.delete({ where: { id } });
await logAudit({ await logAudit({
request, request,

View File

@@ -22,7 +22,7 @@ export const AttendanceBalancesSchema = z.object({
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.optional(), .optional(),
action_type: z.string(), action_type: z.enum(["edit", "reset"]),
vacation_total: z vacation_total: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
@@ -55,7 +55,10 @@ export const AttendanceLeaveSchema = z.object({
.optional(), .optional(),
date_from: z.string().min(1, "Datum je povinné"), date_from: z.string().min(1, "Datum je povinné"),
date_to: z.string().optional(), date_to: z.string().optional(),
leave_type: z.string().optional().default("vacation"), leave_type: z
.enum(["vacation", "sick", "unpaid", "holiday"])
.optional()
.default("vacation"),
leave_hours: z leave_hours: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
@@ -65,16 +68,8 @@ export const AttendanceLeaveSchema = z.object({
const ProjectLogSchema = z.object({ const ProjectLogSchema = z.object({
project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
hours: z hours: z.coerce.number().min(0).default(0),
.union([z.number(), z.string()]) minutes: z.coerce.number().min(0).default(0),
.transform((v) => Number(v) || 0)
.optional()
.default(0),
minutes: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.optional()
.default(0),
}); });
export const AttendancePunchSchema = z.object({ export const AttendancePunchSchema = z.object({
@@ -124,7 +119,10 @@ export const CreateAttendanceSchema = z.object({
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.nullish(), .nullish(),
leave_type: z.string().optional().default("work"), leave_type: z
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
.optional()
.default("work"),
leave_hours: z leave_hours: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
@@ -138,8 +136,13 @@ export const UpdateAttendanceSchema = z.object({
break_start: z.union([z.string(), z.null()]).optional(), break_start: z.union([z.string(), z.null()]).optional(),
break_end: z.union([z.string(), z.null()]).optional(), break_end: z.union([z.string(), z.null()]).optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
project_id: z.union([z.number(), z.string(), z.null()]).optional(), project_id: z
leave_type: z.string().optional(), .union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
leave_type: z
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
.optional(),
leave_hours: z.union([z.number(), z.string(), z.null()]).optional(), leave_hours: z.union([z.number(), z.string(), z.null()]).optional(),
project_logs: z.array(ProjectLogSchema).optional(), project_logs: z.array(ProjectLogSchema).optional(),
}); });

View File

@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
export const TotpVerifySchema = z.object({ export const TotpVerifySchema = z.object({
login_token: z.string().min(1, "Token je povinný"), login_token: z.string().min(1, "Token je povinný"),
totp_code: z.string().length(6, "Kód musí mít 6 číslic"), totp_code: z.string().length(6, "Kód musí mít 6 číslic"),
remember_me: z.boolean().optional().default(false),
}); });
export const TotpBackupSchema = z.object({ export const TotpBackupSchema = z.object({
@@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({
backup_code: z.string().min(1, "Záložní kód je povinný"), backup_code: z.string().min(1, "Záložní kód je povinný"),
}); });
export const TotpEnableSchema = z.object({
secret: z.string().min(1),
code: z.string().min(1),
password: z.string().optional(),
current_code: z.string().optional(),
});
export const TotpDisableSchema = z.object({
code: z.string().min(1),
password: z.string().optional(),
});
export const TotpRequiredSchema = z.object({
required: z.boolean(),
});
export type LoginInput = z.infer<typeof LoginSchema>; export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>; export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>; export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

View File

@@ -13,7 +13,7 @@ export const CreateCustomerSchema = z.object({
}); });
export const UpdateCustomerSchema = z.object({ export const UpdateCustomerSchema = z.object({
name: z.string().optional(), name: z.string().min(1, "Název zákazníka je povinný").optional(),
street: z.string().nullish(), street: z.string().nullish(),
city: z.string().nullish(), city: z.string().nullish(),
postal_code: z.string().nullish(), postal_code: z.string().nullish(),

View File

@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
description: z.string().nullish(), description: z.string().nullish(),
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 1) .transform((v) => {
const n = Number(v);
if (isNaN(n) || n <= 0) throw new Error("Invalid quantity");
return n;
})
.optional() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 0) .transform((v) => {
const n = Number(v);
if (isNaN(n)) throw new Error("Invalid unit_price");
return n;
})
.optional() .optional()
.default(0), .default(0),
vat_rate: z vat_rate: z
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().nullish(), bank_iban: z.string().nullish(),
bank_account: z.string().nullish(), bank_account: z.string().nullish(),
issued_by: z.string().nullish(), issued_by: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(), customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))

View File

@@ -1,14 +1,18 @@
import { z } from "zod"; import { z } from "zod";
export const CreateLeaveRequestSchema = z.object({ export const CreateLeaveRequestSchema = z.object({
leave_type: z.string().min(1, "Typ nepřítomnosti je povinný"), leave_type: z
.enum(["vacation", "sick", "unpaid", "holiday"])
.default("vacation"),
date_from: z.string().min(1, "Datum od je povinné"), date_from: z.string().min(1, "Datum od je povinné"),
date_to: z.string().min(1, "Datum do je povinné"), date_to: z.string().min(1, "Datum do je povinné"),
notes: z.string().nullish(), notes: z.string().nullish(),
}); });
export const ReviewLeaveRequestSchema = z.object({ export const ReviewLeaveRequestSchema = z.object({
status: z.string().min(1, "Stav je povinný"), status: z
.enum(["pending", "approved", "rejected", "cancelled"])
.default("pending"),
reviewer_note: z.string().nullish(), reviewer_note: z.string().nullish(),
}); });

View File

@@ -5,13 +5,15 @@ const QuotationItemSchema = z.object({
item_description: z.string().nullish(), item_description: z.string().nullish(),
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 1) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 0) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(0), .default(0),
is_included_in_total: z is_included_in_total: z
@@ -21,6 +23,7 @@ const QuotationItemSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -31,6 +34,7 @@ const ScopeSectionSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -40,6 +44,7 @@ export const CreateQuotationSchema = z.object({
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
valid_until: z.string().nullish(), valid_until: z.string().nullish(),
currency: z.string().optional().default("CZK"), currency: z.string().optional().default("CZK"),
@@ -47,6 +52,7 @@ export const CreateQuotationSchema = z.object({
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(21.0), .default(21.0),
apply_vat: z apply_vat: z
@@ -56,9 +62,13 @@ export const CreateQuotationSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1.0), .default(1.0),
status: z.string().optional().default("active"), status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional()
.default("nova"),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),
@@ -66,11 +76,11 @@ export const CreateQuotationSchema = z.object({
}); });
export const UpdateQuotationSchema = z.object({ export const UpdateQuotationSchema = z.object({
quotation_number: z.string().optional(),
project_code: z.string().nullish(), project_code: z.string().nullish(),
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
valid_until: z.union([z.string(), z.null()]).optional(), valid_until: z.union([z.string(), z.null()]).optional(),
currency: z.string().optional(), currency: z.string().optional(),
@@ -78,6 +88,7 @@ export const UpdateQuotationSchema = z.object({
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
apply_vat: z apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
@@ -85,8 +96,11 @@ export const UpdateQuotationSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(), .optional(),
status: z.string().optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),

View File

@@ -5,13 +5,15 @@ const OrderItemSchema = z.object({
item_description: z.string().nullish(), item_description: z.string().nullish(),
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 1) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v) || 0) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(0), .default(0),
is_included_in_total: z is_included_in_total: z
@@ -21,6 +23,7 @@ const OrderItemSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
@@ -31,11 +34,15 @@ const OrderSectionSchema = z.object({
position: z position: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
}); });
export const CreateOrderFromQuotationSchema = z.object({ export const CreateOrderFromQuotationSchema = z.object({
quotationId: z.union([z.number(), z.string()]).transform((v) => Number(v)), quotationId: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" }),
customerOrderNumber: z.string().optional().default(""), customerOrderNumber: z.string().optional().default(""),
}); });
@@ -45,17 +52,23 @@ export const CreateOrderSchema = z.object({
quotation_id: z quotation_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
customer_id: z customer_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(), .nullish(),
status: z.string().optional().default("prijata"), status: z
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
.optional()
.default("prijata"),
currency: z.string().optional().default("CZK"), currency: z.string().optional().default("CZK"),
language: z.string().optional().default("cs"), language: z.string().optional().default("cs"),
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(21.0), .default(21.0),
apply_vat: z apply_vat: z
@@ -65,6 +78,7 @@ export const CreateOrderSchema = z.object({
exchange_rate: z exchange_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional() .optional()
.default(1.0), .default(1.0),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
@@ -75,18 +89,21 @@ export const CreateOrderSchema = z.object({
}); });
export const UpdateOrderSchema = z.object({ export const UpdateOrderSchema = z.object({
order_number: z.string().nullish(),
customer_order_number: z.string().nullish(), customer_order_number: z.string().nullish(),
status: z.string().optional(), status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
currency: z.string().optional(), currency: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
notes: z.string().nullish(), notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(), customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
apply_vat: z apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())

View File

@@ -5,7 +5,7 @@ export const UpdateProfileSchema = z.object({
first_name: z.string().optional(), first_name: z.string().optional(),
last_name: z.string().optional(), last_name: z.string().optional(),
current_password: z.string().optional(), current_password: z.string().optional(),
new_password: z.string().optional(), new_password: z.string().min(8, "Heslo musí mít alespoň 8 znaků").optional(),
}); });
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>; export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;

View File

@@ -1,39 +1,25 @@
import { z } from "zod"; import { z } from "zod";
export const CreateProjectSchema = z.object({
project_number: z.string().nullish(),
name: z.string().nullish(),
customer_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.nullish(),
responsible_user_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.nullish(),
quotation_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.nullish(),
order_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.nullish(),
status: z.string().optional().default("aktivni"),
start_date: z.string().nullish(),
end_date: z.string().nullish(),
notes: z.string().nullish(),
});
export const UpdateProjectSchema = z.object({ export const UpdateProjectSchema = z.object({
project_number: z.string().nullish(),
name: z.string().nullish(), name: z.string().nullish(),
status: z.string().optional(), status: z.string().optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(), customer_id: z
responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(), .union([z.number(), z.string(), z.null()])
quotation_id: z.union([z.number(), z.string(), z.null()]).optional(), .transform((v) => (v === null ? null : Number(v)))
order_id: z.union([z.number(), z.string(), z.null()]).optional(), .optional(),
responsible_user_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
quotation_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
order_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
start_date: z.union([z.string(), z.null()]).optional(), start_date: z.union([z.string(), z.null()]).optional(),
end_date: z.union([z.string(), z.null()]).optional(), end_date: z.union([z.string(), z.null()]).optional(),
}); });
@@ -42,6 +28,5 @@ export const CreateProjectNoteSchema = z.object({
content: z.string().nullish(), content: z.string().nullish(),
}); });
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>; export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>; export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;

View File

@@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({
.default(0), .default(0),
issue_date: z.string().nullish(), issue_date: z.string().nullish(),
due_date: z.string().nullish(), due_date: z.string().nullish(),
status: z.string().optional().default("unpaid"), status: z.enum(["unpaid", "paid"]).optional().default("unpaid"),
notes: z.string().nullish(), notes: z.string().nullish(),
}); });
@@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({
issue_date: z.union([z.string(), z.null()]).optional(), issue_date: z.union([z.string(), z.null()]).optional(),
due_date: z.union([z.string(), z.null()]).optional(), due_date: z.union([z.string(), z.null()]).optional(),
paid_date: z.union([z.string(), z.null()]).optional(), paid_date: z.union([z.string(), z.null()]).optional(),
status: z.string().optional(), status: z.enum(["unpaid", "paid"]).optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
month: z month: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])

View File

@@ -2,6 +2,8 @@ import { z } from "zod";
export const CreateTripSchema = z.object({ export const CreateTripSchema = z.object({
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
// user_id is optional here because the route injects it from authData.userId
// when the client doesn't provide it (see POST /trips handler)
user_id: z user_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))

View File

@@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"), password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
first_name: z.string().min(1, "Jméno je povinné"), first_name: z.string().min(1, "Jméno je povinné"),
last_name: z.string().min(1, "Příjmení je povinné"), last_name: z.string().min(1, "Příjmení je povinné"),
role_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), role_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional(),
is_active: z is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional() .optional()
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
), ),
first_name: z.string().optional(), first_name: z.string().optional(),
last_name: z.string().optional(), last_name: z.string().optional(),
role_id: z.union([z.number(), z.string(), z.null()]).optional(), role_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
is_active: z is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(), .optional(),

View File

@@ -1,4 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
@@ -29,17 +30,21 @@ import totpRoutes from "./routes/admin/totp";
import scopeTemplatesRoutes from "./routes/admin/scope-templates"; import scopeTemplatesRoutes from "./routes/admin/scope-templates";
import invoicesPdfRoutes from "./routes/admin/invoices-pdf"; import invoicesPdfRoutes from "./routes/admin/invoices-pdf";
import offersPdfRoutes from "./routes/admin/offers-pdf"; import offersPdfRoutes from "./routes/admin/offers-pdf";
import ordersPdfRoutes from "./routes/admin/orders-pdf";
import projectFilesRoutes from "./routes/admin/project-files"; import projectFilesRoutes from "./routes/admin/project-files";
const app = Fastify({ const app = Fastify({
logger: { logger: {
level: config.isProduction ? "warn" : "info", level: config.isProduction ? "warn" : "info",
}, },
trustProxy: true, trustProxy:
bodyLimit: 1048576, config.trustProxy.length > 0 ? config.trustProxy : ["127.0.0.1", "::1"],
bodyLimit: config.nas.maxUploadSize,
}); });
async function start() { async function start() {
let invoiceAlertCron: ScheduledTask | null = null;
// --- Plugins --- // --- Plugins ---
await app.register(cors, { await app.register(cors, {
origin: origin:
@@ -57,6 +62,12 @@ async function start() {
await app.register(cookie); await app.register(cookie);
// --- Health check (before rate-limit so monitoring isn't throttled) ---
app.get("/api/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
}));
await app.register(rateLimit, { await app.register(rateLimit, {
max: 300, max: 300,
timeWindow: "1 minute", timeWindow: "1 minute",
@@ -116,16 +127,11 @@ async function start() {
}); });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" }); await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
await app.register(ordersPdfRoutes, { prefix: "/api/admin/orders-pdf" });
await app.register(projectFilesRoutes, { await app.register(projectFilesRoutes, {
prefix: "/api/admin/project-files", prefix: "/api/admin/project-files",
}); });
// --- Health check ---
app.get("/api/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
}));
// --- Frontend: Vite dev middleware (dev only) --- // --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) { if (!config.isProduction) {
const viteModule = await (Function( const viteModule = await (Function(
@@ -180,7 +186,7 @@ async function start() {
// --- Invoice alert cron (daily at 8:00 AM) --- // --- Invoice alert cron (daily at 8:00 AM) ---
if (config.email.invoiceAlert) { if (config.email.invoiceAlert) {
const cron = await import("node-cron"); const cron = await import("node-cron");
cron.default.schedule("0 8 * * *", async () => { invoiceAlertCron = cron.default.schedule("0 8 * * *", async () => {
try { try {
const { checkInvoiceAlerts } = const { checkInvoiceAlerts } =
await import("./services/invoice-alerts"); await import("./services/invoice-alerts");
@@ -205,6 +211,9 @@ async function start() {
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
app.log.info(`${signal} received, shutting down gracefully...`); app.log.info(`${signal} received, shutting down gracefully...`);
try { try {
if (invoiceAlertCron) {
invoiceAlertCron.stop();
}
await app.close(); await app.close();
const { default: prisma } = await import("./config/database"); const { default: prisma } = await import("./config/database");
await prisma.$disconnect(); await prisma.$disconnect();

View File

@@ -4,23 +4,16 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date"; import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings"; import { getSystemSettings } from "./system-settings";
/** Get active users whose role has attendance.record permission (or admin role) */ /** Get active users whose role has attendance.record permission */
async function getAttendanceUsers() { async function getAttendanceUsers() {
return prisma.users.findMany({ return prisma.users.findMany({
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "attendance.record" } }, some: { permissions: { name: "attendance.record" } },
}, },
}, },
],
},
},
}, },
select: { id: true, first_name: true, last_name: true }, select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
@@ -73,11 +66,13 @@ function calcWorkedHours(
} }
const roundUp = (d: Date, minutes: number) => { const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000; const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms); return new Date(Math.ceil(d.getTime() / ms) * ms);
}; };
const roundDown = (d: Date, minutes: number) => { const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000; const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms); return new Date(Math.floor(d.getTime() / ms) * ms);
}; };
@@ -254,10 +249,7 @@ export async function getStatus(userId: number) {
} }
const worked = Math.round(workedHours * 100) / 100; const worked = Math.round(workedHours * 100) / 100;
const holidayDays = monthRecords.filter( const adjustedFund = Math.max(0, fund);
(r) => (r.leave_type as string) === "holiday",
).length;
const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8);
const leaveHours = vacationHours + sickHours; const leaveHours = vacationHours + sickHours;
const covered = worked + leaveHours; const covered = worked + leaveHours;
const remaining = Math.max(0, adjustedFund - covered); const remaining = Math.max(0, adjustedFund - covered);
@@ -266,7 +258,7 @@ export async function getStatus(userId: number) {
const monthlyFund = { const monthlyFund = {
month_name: `${MONTH_NAMES[m]} ${y}`, month_name: `${MONTH_NAMES[m]} ${y}`,
fund: adjustedFund, fund: adjustedFund,
business_days: workingDays - holidayDays, business_days: workingDays,
worked, worked,
covered, covered,
remaining, remaining,
@@ -389,8 +381,18 @@ export async function updateAddress(
address: string | null, address: string | null,
punchAction: string, punchAction: string,
) { ) {
// When updating departure address, the punch already set departure_time,
// so we can't filter on departure_time: null. Find the latest record instead.
const where: Record<string, unknown> = {
user_id: userId,
arrival_time: { not: null },
};
if (punchAction === "arrival") {
where.departure_time = null;
}
const latest = await prisma.attendance.findFirst({ const latest = await prisma.attendance.findFirst({
where: { user_id: userId }, where,
orderBy: { created_at: "desc" }, orderBy: { created_at: "desc" },
}); });
if (!latest) return { error: "Nenalezen záznam" }; if (!latest) return { error: "Nenalezen záznam" };
@@ -419,17 +421,32 @@ export async function switchProject(userId: number, projectId: number | null) {
const now = new Date(); const now = new Date();
await prisma.attendance_project_logs.updateMany({ // End active project logs, ensuring ended_at is never before started_at
// (can happen when arrival_time was rounded up and now is still earlier)
const activeLogs = await prisma.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null }, where: { attendance_id: ongoing.id, ended_at: null },
data: { ended_at: now },
}); });
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > now ? log.started_at : now;
await prisma.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
});
}
if (projectId) { if (projectId) {
const existingLogs = await prisma.attendance_project_logs.count({
where: { attendance_id: ongoing.id },
});
const isFirstProject = existingLogs === 0;
let startedAt = isFirstProject ? ongoing.arrival_time! : now;
if (startedAt > now) startedAt = now;
await prisma.attendance_project_logs.create({ await prisma.attendance_project_logs.create({
data: { data: {
attendance_id: ongoing.id, attendance_id: ongoing.id,
project_id: projectId, project_id: projectId,
started_at: now, started_at: startedAt,
ended_at: null, ended_at: null,
}, },
}); });
@@ -501,12 +518,6 @@ export async function getWorkfund(year: number) {
}; };
} }
const yearStart = new Date(year, 0, 1);
const yearEnd = new Date(year, maxMonth + 1, 0, 23, 59, 59);
const allRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: yearStart, lte: yearEnd } },
});
const months: Record< const months: Record<
string, string,
{ {
@@ -537,6 +548,19 @@ export async function getWorkfund(year: number) {
const fundToDate = bizDaysToDate * 8; const fundToDate = bizDaysToDate * 8;
const monthStart = new Date(year, m, 1); const monthStart = new Date(year, m, 1);
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59); const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
const monthRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: monthStart, lte: monthEnd } },
select: {
user_id: true,
shift_date: true,
leave_type: true,
arrival_time: true,
departure_time: true,
break_start: true,
break_end: true,
leave_hours: true,
},
});
const monthUsers: Record< const monthUsers: Record<
string, string,
@@ -550,16 +574,11 @@ export async function getWorkfund(year: number) {
> = {}; > = {};
for (const u of users) { for (const u of users) {
const recs = allRecords.filter( const recs = monthRecords.filter((r) => r.user_id === u.id);
(r) =>
r.user_id === u.id &&
r.shift_date >= monthStart &&
r.shift_date <= monthEnd,
);
let worked = 0; let worked = 0;
let vacationHours = 0; let vacationHours = 0;
let sickHours = 0; let sickHours = 0;
let holidayDays = 0; let holidayHours = 0;
for (const rec of recs) { for (const rec of recs) {
const lt = (rec.leave_type as string) || "work"; const lt = (rec.leave_type as string) || "work";
@@ -577,13 +596,13 @@ export async function getWorkfund(year: number) {
} else if (lt === "sick") { } else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8; sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") { } else if (lt === "holiday") {
holidayDays++; holidayHours += Number(rec.leave_hours) || 8;
} }
} }
const userFund = fundToDate; const userFund = fundToDate;
const workedRound = Math.round(worked * 10) / 10; const workedRound = Math.round(worked * 10) / 10;
const leaveHours = vacationHours + sickHours; const leaveHours = vacationHours + sickHours + holidayHours;
const covered = Math.round((worked + leaveHours) * 10) / 10; const covered = Math.round((worked + leaveHours) * 10) / 10;
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
@@ -630,19 +649,28 @@ export async function getProjectReport(year: number) {
}, },
include: { include: {
users: { select: { id: true, first_name: true, last_name: true } }, users: { select: { id: true, first_name: true, last_name: true } },
attendance_project_logs: {
orderBy: { started_at: "asc" },
},
}, },
}); });
const projectIds = [ // Collect all project ids from both attendance.project_id and project logs
...new Set(records.filter((r) => r.project_id).map((r) => r.project_id!)), const projectIds = new Set<number>();
]; for (const rec of records) {
if (rec.project_id) projectIds.add(rec.project_id);
for (const log of rec.attendance_project_logs) {
projectIds.add(log.project_id);
}
}
const projectsMap = new Map< const projectsMap = new Map<
number, number,
{ name: string; project_number: string } { name: string; project_number: string }
>(); >();
if (projectIds.length > 0) { if (projectIds.size > 0) {
const projects = await prisma.projects.findMany({ const projects = await prisma.projects.findMany({
where: { id: { in: projectIds } }, where: { id: { in: [...projectIds] } },
select: { id: true, name: true, project_number: true }, select: { id: true, name: true, project_number: true },
}); });
for (const p of projects) { for (const p of projects) {
@@ -686,13 +714,20 @@ export async function getProjectReport(year: number) {
>(); >();
for (const rec of monthRecs) { for (const rec of monthRecs) {
const uid = rec.user_id;
const uName = rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
: `User #${uid}`;
if (rec.attendance_project_logs.length === 0) {
// No detailed project logs — fall back to attendance.project_id
const pid = rec.project_id;
const hours = calcWorkedHours( const hours = calcWorkedHours(
rec.arrival_time!, rec.arrival_time!,
rec.departure_time!, rec.departure_time!,
rec.break_start, rec.break_start,
rec.break_end, rec.break_end,
); );
const pid = rec.project_id;
if (!projectMap.has(pid)) { if (!projectMap.has(pid)) {
const projInfo = pid ? projectsMap.get(pid) : undefined; const projInfo = pid ? projectsMap.get(pid) : undefined;
@@ -704,14 +739,43 @@ export async function getProjectReport(year: number) {
} }
const pg = projectMap.get(pid)!; const pg = projectMap.get(pid)!;
const uid = rec.user_id;
const uName = rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
: `User #${uid}`;
if (!pg.userMap.has(uid)) { if (!pg.userMap.has(uid)) {
pg.userMap.set(uid, { name: uName, hours: 0 }); pg.userMap.set(uid, { name: uName, hours: 0 });
} }
pg.userMap.get(uid)!.hours += hours; pg.userMap.get(uid)!.hours += hours;
continue;
}
// Use detailed project logs (started_at/ended_at or hours/minutes)
for (const log of rec.attendance_project_logs) {
let hours = 0;
if (log.hours != null || log.minutes != null) {
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
} else if (log.started_at && log.ended_at) {
hours =
(new Date(log.ended_at).getTime() -
new Date(log.started_at).getTime()) /
(1000 * 60 * 60);
} else {
continue;
}
const pid = log.project_id;
if (!projectMap.has(pid)) {
const projInfo = projectsMap.get(pid);
projectMap.set(pid, {
project_number: projInfo?.project_number || undefined,
project_name: projInfo?.name || undefined,
userMap: new Map(),
});
}
const pg = projectMap.get(pid)!;
if (!pg.userMap.has(uid)) {
pg.userMap.set(uid, { name: uName, hours: 0 });
}
pg.userMap.get(uid)!.hours += hours;
}
} }
const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({ const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({
@@ -1080,6 +1144,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
let inserted = 0; let inserted = 0;
let skipped = 0; let skipped = 0;
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) { for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) { for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day); const date = new Date(yr, mo - 1, day);
@@ -1093,10 +1158,10 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
continue; continue;
} }
const shiftDate = new Date(Date.UTC(yr, mo - 1, day, 12, 0, 0)); const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
if (isHoliday(dateStr)) { if (isHoliday(dateStr)) {
await prisma.attendance.create({ await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1108,7 +1173,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
continue; continue;
} }
await prisma.attendance.create({ await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1122,6 +1187,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
inserted++; inserted++;
} }
} }
});
let msg = `Vytvořeno ${inserted} záznamů`; let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`; if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
@@ -1149,22 +1215,28 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const end = new Date(dateTo); const end = new Date(dateTo);
let created = 0; let created = 0;
try {
await prisma.$transaction(async (tx) => {
const current = new Date(start); const current = new Date(start);
while (current <= end) { while (current <= end) {
const dow = current.getDay(); const dow = current.getDay();
if (dow !== 0 && dow !== 6) { if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current); const dateStr = localDateStr(current);
const shiftDate = new Date( const shiftDate = new Date(
Date.UTC(
current.getFullYear(), current.getFullYear(),
current.getMonth(), current.getMonth(),
current.getDate(), current.getDate(),
12, 12,
0, 0,
0, 0,
),
); );
await prisma.attendance.create({ const duplicate = await tx.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
throw new Error("Pro zvolené datumy již existují záznamy docházky");
}
await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1185,21 +1257,22 @@ export async function createLeave(data: LeaveData, authUserId: number) {
totalLeaveHours > 0 totalLeaveHours > 0
) { ) {
const year = new Date(dateFrom).getFullYear(); const year = new Date(dateFrom).getFullYear();
const existingBalance = await prisma.leave_balances.findFirst({ const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year }, where: { user_id: userId, year },
}); });
if (existingBalance) { if (existingBalance) {
const updateField = const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used"; leaveType === "vacation" ? "vacation_used" : "sick_used";
await prisma.leave_balances.update({ await tx.leave_balances.update({
where: { id: existingBalance.id }, where: { id: existingBalance.id },
data: { data: {
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours, [updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(), updated_at: new Date(),
}, },
}); });
} else { } else {
await prisma.leave_balances.create({ await tx.leave_balances.create({
data: { data: {
user_id: userId, user_id: userId,
year, year,
@@ -1210,6 +1283,16 @@ export async function createLeave(data: LeaveData, authUserId: number) {
}); });
} }
} }
});
} catch (err) {
if (
err instanceof Error &&
err.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return { error: err.message };
}
throw err;
}
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` }; return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
} }
@@ -1221,7 +1304,7 @@ export async function punchAction(userId: number, data: PunchData) {
const y = now.getFullYear(), const y = now.getFullYear(),
m = now.getMonth(), m = now.getMonth(),
d = now.getDate(); d = now.getDate();
const today = new Date(Date.UTC(y, m, d, 12, 0, 0)); const today = new Date(y, m, d, 12, 0, 0);
const gpsLat = const gpsLat =
data.latitude != null && data.latitude !== "" data.latitude != null && data.latitude !== ""
@@ -1315,10 +1398,20 @@ export async function punchAction(userId: number, data: PunchData) {
data: updateData, data: updateData,
}); });
await prisma.attendance_project_logs.updateMany({ // End active project logs, ensuring ended_at is never before started_at
const activeLogs = await prisma.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null }, where: { attendance_id: ongoing.id, ended_at: null },
data: { ended_at: departureTime },
}); });
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > departureTime
? log.started_at
: departureTime;
await prisma.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
});
}
return { return {
id: ongoing.id, id: ongoing.id,
@@ -1341,8 +1434,17 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." }; return { error: "Nemáte aktivní směnu bez přestávky." };
} }
const msRound = settings.clock_rounding_minutes * 60 * 1000; let msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound); if (
!settings.clock_rounding_minutes ||
settings.clock_rounding_minutes <= 0
) {
msRound = 0;
}
const breakStart =
msRound > 0
? new Date(Math.round(now.getTime() / msRound) * msRound)
: now;
const breakEnd = new Date( const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000, breakStart.getTime() + settings.break_duration_long * 60 * 1000,
); );
@@ -1368,10 +1470,36 @@ export async function createAttendance(
data: CreateAttendanceData, data: CreateAttendanceData,
authUserId: number, authUserId: number,
) { ) {
const userId = data.user_id ?? authUserId;
const shiftDate = new Date(data.shift_date);
const startOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate(),
);
const endOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate() + 1,
);
const duplicate = await prisma.attendance.findFirst({
where: {
user_id: userId,
shift_date: { gte: startOfDay, lt: endOfDay },
},
});
if (duplicate) {
return {
error: "Pro zvolené datumy již existují záznamy docházky",
status: 400,
};
}
const record = await prisma.attendance.create({ const record = await prisma.attendance.create({
data: { data: {
user_id: data.user_id ?? authUserId, user_id: userId,
shift_date: new Date(data.shift_date), shift_date: shiftDate,
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null, arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
arrival_lat: data.arrival_lat ?? null, arrival_lat: data.arrival_lat ?? null,
arrival_lng: data.arrival_lng ?? null, arrival_lng: data.arrival_lng ?? null,

View File

@@ -2,6 +2,27 @@ import { FastifyRequest } from "fastify";
import prisma from "../config/database"; import prisma from "../config/database";
import { AuditAction, EntityType, AuthData } from "../types"; import { AuditAction, EntityType, AuthData } from "../types";
/**
* Safe JSON.stringify replacer that handles Prisma Decimal and BigInt
* by converting them to strings. Prevents JSON.stringify from throwing
* on values that include these types.
*/
function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "bigint") return String(value);
if (
value !== null &&
typeof value === "object" &&
"toString" in value &&
typeof (value as any).toString === "function" &&
"toNumber" in value &&
typeof (value as any).toNumber === "function"
) {
// Prisma Decimal
return (value as any).toString();
}
return value;
}
export async function logAudit(params: { export async function logAudit(params: {
request: FastifyRequest; request: FastifyRequest;
authData?: AuthData | null; authData?: AuthData | null;
@@ -22,13 +43,27 @@ export async function logAudit(params: {
entity_type: params.entityType ?? null, entity_type: params.entityType ?? null,
entity_id: params.entityId ?? null, entity_id: params.entityId ?? null,
description: params.description ?? null, description: params.description ?? null,
old_values: params.oldValues ? JSON.stringify(params.oldValues) : null, old_values: params.oldValues
new_values: params.newValues ? JSON.stringify(params.newValues) : null, ? JSON.stringify(params.oldValues, safeJsonReplacer)
: null,
new_values: params.newValues
? JSON.stringify(params.newValues, safeJsonReplacer)
: null,
user_agent: params.request.headers["user-agent"] ?? null, user_agent: params.request.headers["user-agent"] ?? null,
session_id: null, session_id: null,
}, },
}); });
} catch (err) { } catch (err) {
console.error("Failed to write audit log:", err); console.error(
"Failed to write audit log:",
{
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
description: params.description,
userId: params.authData?.userId,
},
err,
);
} }
} }

View File

@@ -51,9 +51,14 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
if (!user || !user.is_active) return null; if (!user || !user.is_active) return null;
if (user.locked_until && new Date(user.locked_until) > new Date())
return null;
const isAdmin = user.roles?.name === "admin"; const isAdmin = user.roles?.name === "admin";
const permissions = isAdmin const permissions = isAdmin
? (await prisma.permissions.findMany()).map((p: { name: string }) => p.name) ? (await prisma.permissions.findMany({ select: { name: true } })).map(
(p) => p.name,
)
: (user.roles?.role_permissions ?? []).map( : (user.roles?.role_permissions ?? []).map(
(rp: { permissions: { name: string } }) => rp.permissions.name, (rp: { permissions: { name: string } }) => rp.permissions.name,
); );
@@ -109,32 +114,47 @@ export async function login(
} }
if (!user.is_active) { if (!user.is_active) {
return { type: "error", message: "Účet je deaktivován", status: 403 }; await bcrypt.compare(password, DUMMY_HASH); // timing-safe
request.log.warn(`Login failed for deactivated user: ${username}`);
return {
type: "error",
message: "Neplatné přihlašovací údaje",
status: 401,
};
} }
if (user.locked_until && new Date(user.locked_until) > new Date()) { if (user.locked_until && new Date(user.locked_until) > new Date()) {
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
request.log.warn(`Login failed for locked user: ${username}`);
return { return {
type: "error", type: "error",
message: "Účet je dočasně uzamčen. Zkuste to později.", message: "Neplatné přihlašovací údaje",
status: 429, status: 401,
}; };
} }
const passwordValid = await bcrypt.compare(password, user.password_hash); const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) { if (!passwordValid) {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1; await prisma.$transaction(async (tx) => {
const updateData: Record<string, unknown> = { const updated = await tx.users.update({
failed_login_attempts: attempts, where: { id: user.id },
}; data: { failed_login_attempts: { increment: 1 } },
select: { failed_login_attempts: true },
});
if (attempts >= settings.max_login_attempts) { if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) {
updateData.locked_until = new Date( await tx.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000, Date.now() + settings.lockout_minutes * 60_000,
); ),
},
});
} }
});
await prisma.users.update({ where: { id: user.id }, data: updateData });
return { return {
type: "error", type: "error",
message: "Neplatné přihlašovací údaje", message: "Neplatné přihlašovací údaje",
@@ -151,6 +171,17 @@ export async function login(
}, },
}); });
const companySettings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
if (companySettings?.require_2fa && !user.totp_enabled) {
return {
type: "error",
message: "Dvoufázové ověření je povinné",
status: 403,
};
}
if (user.totp_enabled) { if (user.totp_enabled) {
const loginToken = crypto.randomBytes(32).toString("hex"); const loginToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(loginToken); const tokenHash = hashToken(loginToken);
@@ -159,7 +190,9 @@ export async function login(
data: { data: {
user_id: user.id, user_id: user.id,
token_hash: tokenHash, token_hash: tokenHash,
expires_at: new Date(Date.now() + 5 * 60_000), // 5 minutes expires_at: new Date(
Date.now() + config.totp.loginTokenExpiryMinutes * 60_000,
),
}, },
}); });
@@ -222,19 +255,35 @@ export async function refreshAccessToken(
> { > {
const tokenHash = hashToken(refreshTokenRaw); const tokenHash = hashToken(refreshTokenRaw);
const storedToken = await prisma.refresh_tokens.findUnique({ return prisma.$transaction(async (tx) => {
where: { token_hash: tokenHash }, const tokens = await tx.$queryRaw<
}); Array<{
id: number;
user_id: number;
expires_at: Date;
replaced_at: Date | null;
replaced_by_hash: string | null;
remember_me: boolean | null;
}>
>`
SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if ( if (
!storedToken || !storedToken ||
storedToken.replaced_at || storedToken.replaced_at ||
storedToken.replaced_by_hash ||
new Date(storedToken.expires_at) < new Date() new Date(storedToken.expires_at) < new Date()
) { ) {
return { type: "error", message: "Neplatný refresh token", status: 401 }; return { type: "error", message: "Neplatný refresh token", status: 401 };
} }
const authData = await loadAuthData(storedToken.user_id); // $queryRaw on MySQL may return BigInt for integer columns
const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id);
const authData = await loadAuthData(storedUserId);
if (!authData) { if (!authData) {
return { type: "error", message: "Uživatel nenalezen", status: 401 }; return { type: "error", message: "Uživatel nenalezen", status: 401 };
} }
@@ -246,22 +295,23 @@ export async function refreshAccessToken(
? config.jwt.refreshTokenRememberExpiry ? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry; : config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([ // $queryRaw on MySQL returns 0/1 for booleans; Prisma expects true/false
prisma.refresh_tokens.update({ const rememberMe = Boolean(storedToken.remember_me);
where: { id: storedToken.id },
await tx.refresh_tokens.update({
where: { id: storedTokenId },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash }, data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}), });
prisma.refresh_tokens.create({ await tx.refresh_tokens.create({
data: { data: {
user_id: storedToken.user_id, user_id: storedUserId,
token_hash: newRefreshTokenHash, token_hash: newRefreshTokenHash,
expires_at: new Date(Date.now() + expiresIn * 1000), expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: storedToken.remember_me, remember_me: rememberMe,
ip_address: request.ip, ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null, user_agent: request.headers["user-agent"] ?? null,
}, },
}), });
]);
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
id: authData.userId, id: authData.userId,
@@ -274,31 +324,18 @@ export async function refreshAccessToken(
accessToken, accessToken,
refreshToken: newRefreshTokenRaw, refreshToken: newRefreshTokenRaw,
user: authData, user: authData,
rememberMe: storedToken.remember_me ?? false, rememberMe: rememberMe,
}; };
});
} }
export async function logout(refreshTokenRaw: string): Promise<void> { export async function logout(refreshTokenRaw: string): Promise<void> {
const tokenHash = hashToken(refreshTokenRaw); const tokenHash = hashToken(refreshTokenRaw);
const token = await prisma.refresh_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (token) { // Delete only the specific token presented, not all sessions
// Delete all tokens for this user from the same IP + user agent (same browser session)
await prisma.refresh_tokens.deleteMany({
where: {
user_id: token.user_id,
ip_address: token.ip_address,
user_agent: token.user_agent,
},
});
} else {
// Fallback: just delete by hash
await prisma.refresh_tokens.deleteMany({ await prisma.refresh_tokens.deleteMany({
where: { token_hash: tokenHash }, where: { token_hash: tokenHash },
}); });
}
await prisma.refresh_tokens.deleteMany({ await prisma.refresh_tokens.deleteMany({
where: { expires_at: { lt: new Date() } }, where: { expires_at: { lt: new Date() } },
@@ -309,12 +346,12 @@ export async function verifyAccessToken(
token: string, token: string,
): Promise<AuthData | null> { ): Promise<AuthData | null> {
try { try {
const payload = jwt.verify( const payload = jwt.verify(token, config.jwt.secret, {
token, algorithms: ["HS256"],
config.jwt.secret, }) as unknown as JwtPayload;
) as unknown as JwtPayload;
return loadAuthData(payload.sub); return loadAuthData(payload.sub);
} catch { } catch (err) {
console.error("JWT verification error:", err);
return null; return null;
} }
} }

View File

@@ -10,14 +10,22 @@ interface CnbRate {
amount: number; amount: number;
} }
const rateCache: Record<string, Record<string, number>> = {}; const rateCache = new Map<string, Record<string, number>>();
let rateCacheTime = 0;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const inflight = new Map<string, Promise<Record<string, number>>>();
async function fetchRatesForDate( async function fetchRatesForDate(
date?: string, date?: string,
): Promise<Record<string, number>> { ): Promise<Record<string, number>> {
const key = date || "today"; const key = date || "today";
if (rateCache[key]) return rateCache[key]; if (Date.now() - rateCacheTime > CACHE_TTL_MS) {
rateCache.clear();
}
if (rateCache.has(key)) return rateCache.get(key)!;
if (inflight.has(key)) return inflight.get(key)!;
const promise = (async () => {
try { try {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`; if (date) url += `&date=${date}`;
@@ -32,13 +40,20 @@ async function fetchRatesForDate(
rates[r.currencyCode] = r.rate / r.amount; rates[r.currencyCode] = r.rate / r.amount;
} }
rateCache[key] = rates; rateCache.set(key, rates);
rateCacheTime = Date.now();
return rates; return rates;
} catch (err) { } catch (err) {
console.error("Failed to fetch CNB exchange rates:", err); console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"]; if (rateCache.has("today")) return rateCache.get("today")!;
return { CZK: 1, EUR: 25, USD: 22, GBP: 28 }; throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
} finally {
inflight.delete(key);
} }
})();
inflight.set(key, promise);
return promise;
} }
/** Convert an amount from a given currency to CZK using CNB rates */ /** Convert an amount from a given currency to CZK using CNB rates */
@@ -50,7 +65,7 @@ export async function toCzk(
if (currency === "CZK") return amount; if (currency === "CZK") return amount;
const rates = await fetchRatesForDate(date); const rates = await fetchRatesForDate(date);
const rate = rates[currency]; const rate = rates[currency];
if (!rate) return amount; if (!rate) throw new Error(`Neznámá měna: ${currency}`);
return Math.round(amount * rate * 100) / 100; return Math.round(amount * rate * 100) / 100;
} }
@@ -61,5 +76,7 @@ export async function getRate(
): Promise<number> { ): Promise<number> {
if (currency === "CZK") return 1; if (currency === "CZK") return 1;
const rates = await fetchRatesForDate(date); const rates = await fetchRatesForDate(date);
return rates[currency] || 1; const rate = rates[currency];
if (!rate) throw new Error(`Neznámá měna: ${currency}`);
return rate;
} }

View File

@@ -37,6 +37,7 @@ export async function checkInvoiceAlerts(): Promise<void> {
if (!alertEmail) return; if (!alertEmail) return;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today); const todayStr = localDateStr(today);
const in3days = new Date(today); const in3days = new Date(today);
in3days.setDate(in3days.getDate() + 3); in3days.setDate(in3days.getDate() + 3);
@@ -48,11 +49,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const createdInvoices = await prisma.invoices.findMany({ const createdInvoices = await prisma.invoices.findMany({
where: { where: {
status: { in: ["issued", "overdue"] }, status: { in: ["issued", "overdue"] },
due_date: { not: null }, due_date: { gte: today, lte: in3days },
}, },
include: { select: {
id: true,
invoice_number: true,
due_date: true,
currency: true,
customers: { select: { name: true } }, customers: { select: { name: true } },
invoice_items: true, invoice_items: { select: { quantity: true, unit_price: true } },
}, },
}); });
@@ -99,21 +104,21 @@ export async function checkInvoiceAlerts(): Promise<void> {
due_date: localDateCzStr(new Date(inv.due_date)), due_date: localDateCzStr(new Date(inv.due_date)),
days_label: daysLabel, days_label: daysLabel,
}); });
await prisma.invoice_alert_log.create({
data: {
invoice_type: "created",
invoice_id: inv.id,
alert_type: alertType,
},
});
} }
// --- Received invoices (we owe supplier) --- // --- Received invoices (we owe supplier) ---
const receivedInvoices = await prisma.received_invoices.findMany({ const receivedInvoices = await prisma.received_invoices.findMany({
where: { where: {
status: "unpaid", status: "unpaid",
due_date: { not: null }, due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
supplier_name: true,
amount: true,
currency: true,
due_date: true,
}, },
}); });
@@ -155,14 +160,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
due_date: localDateCzStr(new Date(inv.due_date)), due_date: localDateCzStr(new Date(inv.due_date)),
days_label: daysLabel, days_label: daysLabel,
}); });
await prisma.invoice_alert_log.create({
data: {
invoice_type: "received",
invoice_id: inv.id,
alert_type: alertType,
},
});
} }
if (alerts.length === 0) return; if (alerts.length === 0) return;
@@ -221,9 +218,26 @@ export async function checkInvoiceAlerts(): Promise<void> {
const sent = await sendMail(alertEmail, subject, html); const sent = await sendMail(alertEmail, subject, html);
if (!sent) { if (!sent) {
console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`); console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`);
} else { return;
console.log( }
`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`,
); console.log(`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`);
// Mark alerts as sent only after successful delivery
for (const a of alerts) {
await prisma.invoice_alert_log.create({
data: {
invoice_type: a.type,
invoice_id: a.id,
alert_type:
a.type === "created"
? a.days_label.includes("dnes")
? "due"
: "3days"
: a.days_label.includes("dnes")
? "due"
: "3days",
},
});
} }
} }

Some files were not shown because too many files have changed in this diff Show More