Compare commits

14 Commits

Author SHA1 Message Date
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
107 changed files with 4595 additions and 2004 deletions

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local
# 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
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# 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
NAS_PATH=Z:/02_PROJEKTY

View File

@@ -35,7 +35,7 @@ src/
├── 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 (56 .tsx files)
├── admin/ # React 18 frontend (57 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
@@ -288,8 +288,15 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
1. Bump version in `package.json`
2. `npm run build`
3. Create a tarball
4. Tag the release in Gitea
5. Deploy via SSH to production server (`boha_admin@192.168.50.100`)
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`
- Backup: `node_modules`, `.env`, `ecosystem.config.js`
- Clean directory (keep backups only)
- Extract tarball
- Restore backups
- Restart: `pm2 reload ecosystem.config.js`
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",
"version": "1.5.1",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "1.5.1",
"version": "1.5.3",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -19,6 +19,7 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
@@ -27,6 +28,7 @@
"file-type": "^16.5.4",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"node-cron": "^4.2.1",
@@ -64,6 +66,53 @@
"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": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -87,6 +136,152 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -630,6 +825,23 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -1513,6 +1725,24 @@
"dev": true,
"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": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -1562,7 +1792,6 @@
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -1639,6 +1868,12 @@
"@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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2088,6 +2323,15 @@
"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": {
"version": "5.0.5",
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2510,6 +2767,19 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2546,6 +2816,12 @@
"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": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -2721,6 +2997,18 @@
"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": {
"version": "2.2.1",
"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==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3617,6 +3917,12 @@
"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": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3644,6 +3950,70 @@
"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": {
"version": "2.3.1",
"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_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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -4477,6 +4853,18 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4741,6 +5129,15 @@
"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": {
"version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
@@ -5257,6 +5654,18 @@
"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": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5469,7 +5878,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -5635,6 +6043,12 @@
"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": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@@ -5768,6 +6182,24 @@
"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": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
@@ -5803,6 +6235,30 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5859,11 +6315,19 @@
"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": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"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": {
"version": "2.0.1",
"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": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

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

View File

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
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([project_id], map: "idx_project_id")
}
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
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([project_id], map: "idx_project_id")
@@ -104,7 +105,7 @@ model company_settings {
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
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)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
@@ -165,7 +166,7 @@ model invoice_items {
model invoices {
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?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -196,6 +197,7 @@ model invoices {
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id")
}
@@ -288,7 +290,7 @@ model order_sections {
model orders {
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)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -340,7 +342,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -352,6 +354,7 @@ model projects {
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
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")
@@ -385,7 +388,7 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50)
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -434,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int? @db.UnsignedInt
uploaded_by Int?
created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0)
@@ -446,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
model refresh_tokens {
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)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
@@ -580,6 +583,7 @@ model users {
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
@@ -626,6 +630,7 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@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

@@ -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

@@ -75,6 +75,19 @@ function NativeInput({
disabled,
}: NativeInputProps) {
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 (
<input
type={type}
@@ -84,14 +97,14 @@ function NativeInput({
className="admin-form-input"
required={required}
disabled={disabled}
min={minDate || undefined}
max={maxDate || undefined}
min={minProp}
max={maxProp}
/>
);
}
interface AdminDatePickerProps {
mode?: "date" | "month" | "datetime" | "time";
mode?: "date" | "month" | "time";
value: string;
onChange: (value: string) => void;
minDate?: string;
@@ -165,17 +178,22 @@ export default function AdminDatePicker({
return undefined;
};
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput: (
const customInput = useMemo(
() => (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
[required, placeholder, disabled],
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,

View File

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

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
}
interface BulkAttendanceModalProps {
show: boolean;
isOpen: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
}
export default function BulkAttendanceModal({
show,
isOpen,
onClose,
form,
setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show);
useModalLock(isOpen);
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
/>
<motion.div
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 }}
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 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
style={{
color: "var(--text-secondary)",

View File

@@ -14,6 +14,71 @@ interface ConfirmModalProps {
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({
isOpen,
onClose,
@@ -39,6 +104,9 @@ export default function ConfirmModal({
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-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 }}
@@ -46,20 +114,11 @@ export default function ConfirmModal({
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<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>
<ConfirmIcon type={type} />
</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>
</div>
<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 {
label: ReactNode;
@@ -15,13 +21,22 @@ export default function FormField({
required,
style,
}: 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 (
<div className="admin-form-group" style={style}>
<label className="admin-form-label">
<label className="admin-form-label" htmlFor={childId}>
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{children}
{childWithId}
{error && <span className="admin-form-error">{error}</span>}
</div>
);

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
description: string;
@@ -13,10 +14,15 @@ interface ConfirmationItem {
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (lang: string, items?: ConfirmationItem[]) => Promise<void>;
onGenerate: (
lang: string,
applyVat: boolean,
items?: ConfirmationItem[],
) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
@@ -26,16 +32,22 @@ export default function OrderConfirmationModal({
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, undefined);
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");
@@ -46,7 +58,10 @@ export default function OrderConfirmationModal({
const handleEditGenerate = async () => {
setLoading(true);
try {
await onGenerate(lang, items);
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");
@@ -102,13 +117,19 @@ export default function OrderConfirmationModal({
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 className="admin-modal-title">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
@@ -144,6 +165,34 @@ export default function OrderConfirmationModal({
</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

View File

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

View File

@@ -197,6 +197,7 @@ export default function ProjectFileManager({
}: ProjectFileManagerProps) {
const alert = useAlert();
const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -768,10 +769,26 @@ export default function ProjectFileManager({
}}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(item);
if (e.key === "Escape") setRenamingItem(null);
if (e.key === "Enter") {
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -224,11 +224,20 @@ function computeUserTotals(
// 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 {
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)
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>';
}
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 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("");
}
return record.project_name || "—";
return escapeHtml(record.project_name || "—");
}
function buildLeaveSummaryHtml(
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
printData: Record<string, any>,
): string {
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)
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)
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)
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)
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>`;
}
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
const breakCell =
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>
<td>${formatDate(record.shift_date)}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></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">${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 style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ""}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`;
})
.join("");
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
userData.fund !== null
? `<tr>
<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>
</tr>`
: "";
return `<div class="user-section">
<div class="user-header">
<h3>${userData.name}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
${leaveHtml}
<table>
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
<tfoot>
<tr>
<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>
</tr>
${fundRow}
@@ -365,7 +374,7 @@ function buildPrintHtml(
<head>
<meta charset="UTF-8">
<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>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -428,11 +437,11 @@ function buildPrintHtml(
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${companyName}</div>
<div class="company">${escapeHtml(companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${pData.month_name}</div>
<div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</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>'
: "";
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(
pData,
@@ -1051,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open();
printWindow.document.write(bodyContent);
printWindow.document.close();
printWindow.onload = () => printWindow.print();
printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
}
}
} catch {

View File

@@ -43,8 +43,14 @@ export default function useListData<T = unknown>(
const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
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 () => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
@@ -66,7 +72,10 @@ export default function useListData<T = unknown>(
? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`;
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();
if (result.success) {
const data = dataKey
@@ -92,8 +101,10 @@ export default function useListData<T = unknown>(
}
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return;
if (!mountedRef.current) return;
alert.error(errorMsg);
} finally {
if (!mountedRef.current) return;
setLoading(false);
setInitialLoad(false);
}
@@ -105,12 +116,14 @@ export default function useListData<T = unknown>(
page,
perPage,
dataKey,
JSON.stringify(extraParams),
]); // eslint-disable-line react-hooks/exhaustive-deps
extraParamsKey,
]);
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort();
};
}, [fetchData]);

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback } from "react";
interface SortState {
sort: string;
@@ -13,10 +13,10 @@ export default function useTableSort(
sort: defaultSort,
order: defaultOrder,
});
const userClicked = useRef(false);
const [userClicked, setUserClicked] = useState(false);
const handleSort = useCallback((column: string) => {
userClicked.current = true;
setUserClicked(true);
setState((prev) => {
if (prev.sort === column) {
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 };
}

View File

@@ -108,7 +108,7 @@ export default function Attendance() {
project_logs: [],
active_project_id: null,
});
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
@@ -122,14 +122,20 @@ export default function Attendance() {
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
show: boolean;
isOpen: boolean;
action: string | null;
}>({ show: false, action: null });
}>({ isOpen: false, action: 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(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
@@ -173,14 +179,25 @@ export default function Attendance() {
loadProjects();
}, []);
useModalLock(showLeaveModal);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
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) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná");
submitPunch(action, {});
return;
@@ -188,14 +205,21 @@ export default function Attendance() {
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
try {
const { latitude, longitude, accuracy } = position.coords;
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();
const controller = new AbortController();
geoAbortRef.current = controller;
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" },
signal: controller.signal,
@@ -203,13 +227,15 @@ export default function Attendance() {
)
.then((r) => r.json())
.then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude,
longitude,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
address: geoData.display_name,
punch_action: action,
}),
@@ -219,6 +245,8 @@ export default function Attendance() {
.catch(() => {});
},
(geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut";
@@ -226,9 +254,10 @@ export default function Attendance() {
errorMsg = "Vypršel časový limit";
}
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) {
await fetchData();
setTimeout(() => {
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
@@ -360,7 +389,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
setShowLeaveModal(false);
setIsLeaveModalOpen(false);
await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -605,7 +634,8 @@ export default function Attendance() {
{projectLogs.length > 0 && (
<div className="attendance-project-logs">
{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
? new Date(log.ended_at)
: new Date();
@@ -661,7 +691,7 @@ export default function Attendance() {
{submitting ? "Zpracovávám..." : "Odchod"}
</button>
<button
onClick={() => setShowLeaveModal(true)}
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
@@ -700,7 +730,7 @@ export default function Attendance() {
</button>
<button
onClick={() => setShowLeaveModal(true)}
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
@@ -757,11 +787,12 @@ export default function Attendance() {
}}
>
{shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at
? Math.floor(
(new Date(log.ended_at).getTime() -
new Date(
log.started_at!,
log.started_at,
).getTime()) /
60000,
)
@@ -1048,7 +1079,7 @@ export default function Attendance() {
{/* Leave Modal */}
<AnimatePresence>
{showLeaveModal && (
{isLeaveModalOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -1058,7 +1089,7 @@ export default function Attendance() {
>
<div
className="admin-modal-backdrop"
onClick={() => setShowLeaveModal(false)}
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
@@ -1179,7 +1210,7 @@ export default function Attendance() {
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowLeaveModal(false)}
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
@@ -1206,13 +1237,13 @@ export default function Attendance() {
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.show}
isOpen={gpsConfirm.isOpen}
onClose={() => {
setGpsConfirm({ show: false, action: null });
setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ show: false, action: null });
setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {});
}}
title="GPS nedostupná"

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,73 @@ const formatBreakRange = (record: AttendanceRecord): string => {
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) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
@@ -185,7 +252,8 @@ export default function AttendanceHistory() {
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours = Number(record.leave_hours) || 8;
const hours =
record.leave_hours != null ? Number(record.leave_hours) : 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
@@ -193,21 +261,9 @@ export default function AttendanceHistory() {
}
}
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const holidayDays = records.filter(
(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 businessDays = getBusinessDaysInMonth(yr, mo);
const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// 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,
}).addTo(map);
marker.bindPopup(
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`,
const popupEl = document.createElement("div");
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) {
L.circle([loc.lat, loc.lng], {

View File

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

View File

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

View File

@@ -313,7 +313,7 @@ export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const keyCounterRef = useRef(0);
const keyCounterRef = useRef(1);
const emptyItem = useCallback(
(): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
@@ -369,11 +369,21 @@ export default function InvoiceDetail() {
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([emptyItem()]);
const [items, setItems] = useState<InvoiceItem[]>([
{
_key: "inv-1",
description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
@@ -390,7 +400,22 @@ export default function InvoiceDetail() {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}
})
.catch(() => {});
}, []);
@@ -402,22 +427,6 @@ export default function InvoiceDetail() {
label: `${v}%`,
}));
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const DRAFT_KEY = "boha_invoice_draft";
const clearDraft = useCallback(() => {
@@ -438,8 +447,15 @@ export default function InvoiceDetail() {
}>({ show: false, status: null });
const [pdfLoading, setPdfLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
// ─── Data loading ───
useEffect(() => {
@@ -586,7 +602,7 @@ export default function InvoiceDetail() {
}
// Populate form state from existing invoice
setForm({
const formData = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
@@ -613,7 +629,8 @@ export default function InvoiceDetail() {
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
});
};
setForm(formData);
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
@@ -626,9 +643,9 @@ export default function InvoiceDetail() {
}
// Populate items from existing invoice
if (inv.items?.length > 0) {
setItems(
inv.items.map((item: Record<string, unknown>) => ({
const mappedItems =
inv.items?.length > 0
? inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
@@ -636,9 +653,17 @@ export default function InvoiceDetail() {
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
})),
);
}))
: [];
if (mappedItems.length > 0) {
setItems(mappedItems);
}
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
});
} else {
alert.error(result.error || "Nepodařilo se načíst fakturu");
navigate("/invoices");
@@ -655,12 +680,33 @@ export default function InvoiceDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
// ─── Due date calculation from issue date + days ───
// Capture initial snapshot for dirty-checking once data finishes loading.
// Edit mode: captured inside fetchDetail from raw API data.
// Create mode: captured on first stable render after loading completes.
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
}, [form, items]);
useEffect(() => {
if (!form.issue_date) return;
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const computedDueDate = useMemo(() => {
if (!form.issue_date) return "";
const d = new Date(form.issue_date);
d.setDate(d.getDate() + dueDays);
setForm((prev) => ({ ...prev, due_date: d.toISOString().split("T")[0] }));
return d.toISOString().split("T")[0];
}, [form.issue_date, dueDays]);
// ─── Create mode: customer filtering ───
@@ -787,6 +833,7 @@ export default function InvoiceDetail() {
try {
const payload: any = {
...form,
due_date: computedDueDate || form.due_date,
items: items
.filter((i) => i.description.trim())
.map((item, i) => ({
@@ -817,6 +864,7 @@ export default function InvoiceDetail() {
result.message ||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
fetchDetail();
} else {
@@ -876,7 +924,8 @@ export default function InvoiceDetail() {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
@@ -1554,10 +1603,10 @@ export default function InvoiceDetail() {
</option>
))}
</select>
{form.due_date && (
{computedDueDate && (
<span className="text-tertiary text-xs mt-1">
Splatnost:{" "}
{new Date(form.due_date).toLocaleDateString("cs-CZ")}
{new Date(computedDueDate).toLocaleDateString("cs-CZ")}
</span>
)}
</FormField>

View File

@@ -138,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -299,9 +309,11 @@ export default function Invoices() {
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
alert.error("Chyba při generování PDF");
} finally {

View File

@@ -163,8 +163,8 @@ export default function LeaveApproval() {
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() -
new Date(a.reviewed_at!).getTime(),
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
);
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 { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni.");
}
}, [alert]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input
useEffect(() => {
@@ -43,21 +44,8 @@ export default function Login() {
}
}, [show2FA, useBackupCode]);
if (authLoading) {
return (
<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) => {
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -78,9 +66,24 @@ export default function Login() {
setAnimatingOut(true);
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();
if (!totpCode.trim()) return;
@@ -105,14 +108,41 @@ export default function Login() {
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
};
},
[
totpCode,
loginToken,
remember,
useBackupCode,
verify2FA,
alert,
setLoading,
setShake,
setTotpCode,
setAnimatingOut,
],
);
const handleBack = () => {
const handleBack = useCallback(() => {
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
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 (
<motion.div

View File

@@ -2,6 +2,7 @@ import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ChangeEvent,
} from "react";
@@ -55,9 +56,6 @@ interface OfferItem {
is_included_in_total: boolean;
}
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection {
title: string;
title_cz: string;
@@ -113,16 +111,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "",
});
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({
item,
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() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -288,12 +289,56 @@ export default function OfferDetail() {
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 [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>([emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [form, setForm] = useState<OfferForm>(() => {
const draft = loadOfferDraft();
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<
Array<{
id: number;
@@ -321,6 +366,7 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
@@ -333,33 +379,40 @@ export default function OfferDetail() {
full_name: string;
} | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
useModalLock(showOrderModal);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
}
})
.catch(() => {});
}, []);
const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
@@ -378,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
const formData = {
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
@@ -394,21 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
});
setItems(
d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() }))
: [emptyItem()],
);
setSections(
d.sections?.length
};
setForm(formData);
const mappedItems = d.items?.length
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()];
setItems(mappedItems);
const mappedSections = d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [],
);
: [];
setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setOfferStatus(d.status || "");
setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null);
@@ -447,10 +507,14 @@ export default function OfferDetail() {
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
// 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]);
@@ -458,6 +522,28 @@ export default function OfferDetail() {
if (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(() => {
const loadCustomers = async () => {
try {
@@ -517,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber();
}, [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)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -669,6 +722,13 @@ export default function OfferDetail() {
if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
}
@@ -781,7 +841,8 @@ export default function OfferDetail() {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba při generování PDF");

View File

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

View File

@@ -3,6 +3,7 @@ import {
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import DOMPurify from "dompurify";
@@ -119,6 +120,14 @@ export default function OrderDetail() {
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 () => {
try {
@@ -128,6 +137,7 @@ export default function OrderDetail() {
if (result.success) {
setOrder(result.data);
setNotes(result.data.notes || "");
initialNotesRef.current = result.data.notes || "";
} else {
alert.error(result.error || "Nepodařilo se načíst objednávku");
navigate("/orders");
@@ -144,6 +154,21 @@ export default function OrderDetail() {
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(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => {
@@ -197,6 +222,7 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} else {
alert.error(result.error || "Nepodařilo se uložit poznámky");
}
@@ -220,7 +246,8 @@ export default function OrderDetail() {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
@@ -231,6 +258,7 @@ export default function OrderDetail() {
const handleGenerateConfirmation = async (
lang: string,
applyVat: boolean,
customItems?: Array<{
description: string;
quantity: number;
@@ -247,7 +275,7 @@ export default function OrderDetail() {
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lang, items: customItems }),
body: JSON.stringify({ lang, applyVat, items: customItems }),
},
);
if (!response.ok) {
@@ -263,7 +291,8 @@ export default function OrderDetail() {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60000);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
alert.error("Chyba připojení");
} finally {
@@ -846,6 +875,7 @@ export default function OrderDetail() {
}))}
orderNumber={order.order_number}
defaultVatRate={Number(order.vat_rate) || 21}
applyVat={!!order.apply_vat}
/>
)}
</div>

View File

@@ -171,9 +171,8 @@ export default function ProjectCreate() {
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.id}`, {
state: { created: true },
});
alert.success("Projekt byl vytvořen");
navigate(`/projects/${data.data.id}`);
} else {
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
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 Forbidden from "../components/Forbidden";
@@ -73,7 +73,6 @@ export default function ProjectDetail() {
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -98,18 +97,6 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false);
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 () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ dotenv.config();
process.env.TZ = process.env.TZ || "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
// JS Date objects with correct UTC internals. The default toJSON() calls
// toISOString() which returns UTC — this override uses local getters instead,
@@ -50,13 +52,27 @@ export const config = {
totp: {
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: {
path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
financialsPath: process.env.NAS_FINANCIALS_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: {
@@ -74,7 +90,41 @@ export const config = {
origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean),
},
trustProxy: (process.env.TRUST_PROXY || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
security: {
bcryptCost: 12,
},
} 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",
"camera=(), microphone=(), geolocation=(self)",
);
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
if (config.isProduction) {
reply.header(
@@ -27,6 +29,9 @@ export async function securityHeaders(
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org",
"connect-src 'self' https://nominatim.openstreetmap.org",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
].join("; "),
);
}

View File

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

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, paginated, error } from "../../utils/response";
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)
if (days === 0 || body.action === "all") {
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ů`);
}
@@ -62,6 +70,13 @@ export default async function auditLogRoutes(
const result = await prisma.audit_logs.deleteMany({
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(
reply,
null,

View File

@@ -92,43 +92,101 @@ export default async function authRoutes(
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp",
{ bodyLimit: 10240 },
{
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data;
const rawBody = request.body as unknown as Record<string, unknown>;
const rememberMe =
rawBody.remember_me === true || rawBody.remember_me === "true";
const { login_token, totp_code, remember_me } = parsed.data;
const rememberMe = remember_me ?? false;
const tokenHash = crypto
.createHash("sha256")
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
const totpResult = 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()) {
return error(reply, "Neplatný nebo expirovaný login token", 401);
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
// $queryRaw on MySQL may return BigInt for integer columns
const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id);
const user = await tx.users.findUnique({
where: { id: storedUserId },
include: { roles: true },
});
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);
}
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) {
const verifyResult = OTPAuth.verify(user.totp_secret, totp_code);
if (!verifyResult.valid) {
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)
await prisma.users.update({
@@ -181,12 +239,33 @@ export default async function authRoutes(
});
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 });
},
);
// 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;
if (!refreshTokenRaw) {
return error(reply, "Refresh token chybí", 401);
@@ -210,10 +289,11 @@ export default async function authRoutes(
access_token: result.accessToken,
user: result.user,
});
});
},
);
// POST /api/admin/logout
fastify.post("/logout", async (request, reply) => {
fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) {
await logout(refreshTokenRaw);

View File

@@ -74,6 +74,17 @@ export default async function companySettingsRoutes(
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 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
)
mime = "image/webp";
return reply
.type(mime)
@@ -124,6 +135,7 @@ export default async function companySettingsRoutes(
);
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
// Use upsert to avoid race condition between findFirst and create
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
@@ -165,7 +177,56 @@ export default async function companySettingsRoutes(
});
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: {
company_name: "",
quotation_prefix: "N",
@@ -210,6 +271,7 @@ export default async function companySettingsRoutes(
invoice_number_pattern: true,
},
});
});
}
if (!settings) return error(reply, "Nastavení nenalezeno", 500);
@@ -324,15 +386,12 @@ export default async function companySettingsRoutes(
nas: {
projects: {
configured: projectNas.isConfigured(),
path: config.nas.path || "—",
},
financials: {
configured: nasFinancialsManager.isConfigured(),
path: config.nas.financialsPath || "—",
},
offers: {
configured: nasOffersManager.isConfigured(),
path: config.nas.offersPath || "—",
},
},
});
@@ -421,7 +480,7 @@ export default async function companySettingsRoutes(
: existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
data.sync_version = { increment: 1 };
await prisma.company_settings.update({
where: { id: existing.id },

View File

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

View File

@@ -179,43 +179,51 @@ export default async function dashboardRoutes(
// Invoices — only for 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.findMany({
where: { issue_date: { gte: monthStart, lt: monthEnd } },
include: { invoice_items: true },
}),
prisma.$queryRaw<
Array<{ currency: string | null; total: string | number | null }>
>`
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> = {};
for (const inv of issuedThisMonth) {
const currency = inv.currency ?? "CZK";
let total = 0;
for (const item of inv.invoice_items) {
total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
for (const row of revenueAgg) {
const currency = row.currency || "CZK";
const amount = Number(row.total) || 0;
revenueByCurrency[currency] =
(revenueByCurrency[currency] ?? 0) + total;
(revenueByCurrency[currency] || 0) + amount;
}
result.invoices = {
revenue_this_month: Object.entries(revenueByCurrency).map(
([currency, amount]) => ({
const revenueConversions = await Promise.all(
Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}),
),
czk: await toCzk(Math.round(amount * 100) / 100, currency),
})),
);
result.invoices = {
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
amount,
currency,
})),
unpaid_count: unpaidCount,
revenue_czk: await (async () => {
let total = 0;
for (const [cur, amount] of Object.entries(revenueByCurrency)) {
total += await toCzk(Math.round(amount * 100) / 100, cur);
}
return Math.round(total * 100) / 100;
})(),
revenue_czk:
Math.round(
revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
) / 100,
};
result.unpaid_invoices = unpaidCount;
}
// Orders — only for orders.view
@@ -231,7 +239,6 @@ export default async function dashboardRoutes(
where: { status: "pending" },
});
result.leave_pending = { count };
result.pending_leave_requests = count;
}
// 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 { getRate } from "../../services/exchange-rates";
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 ─────────────────────────────────────────────────────── */
@@ -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(/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(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
@@ -78,7 +92,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
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 as Record<string, unknown>).fields) {
cfData =
@@ -248,10 +267,13 @@ export default async function invoicesPdfRoutes(
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.export") },
{ preHandler: requirePermission("invoices.view") },
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>;
try {
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
@@ -331,7 +353,8 @@ export default async function invoicesPdfRoutes(
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
if (applyVat) {
vatSummary[key].vat += (lineSubtotal * rate) / 100;
vatSummary[key].vat +=
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
}
}
@@ -472,7 +495,7 @@ export default async function invoicesPdfRoutes(
<!-- Poznamky -->
<div class="invoice-notes">
<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>
`
: "";
@@ -1000,33 +1023,36 @@ ${indentCSS}
</body>
</html>`;
const saveMode = query.save === "1";
// Save PDF to NAS
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
if (
saveMode &&
nasFinancialsManager.isConfigured() &&
invoice.invoice_number
) {
const issueDate = invoice.issue_date
? new Date(invoice.issue_date)
: new Date();
const saveMode = query.save === "1";
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
const pdfBuffer = await htmlToPdf(html);
nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!,
issueDate.getFullYear(),
issueDate.getMonth() + 1,
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.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

@@ -26,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
export default async function invoicesRoutes(
fastify: FastifyInstance,
): 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) => {
if (request.method !== "GET") return;
if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) {
lastOverdueCheck = Date.now();
await markOverdueInvoices();
}
});
// GET /api/admin/invoices
@@ -191,10 +197,8 @@ export default async function invoicesRoutes(
if (!existing) return error(reply, "Faktura nenalezena", 404);
// Delete PDF from NAS
if (existing.invoice_number && existing.issue_date) {
const d = new Date(existing.issue_date);
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
nasFinancialsManager.deleteIssuedInvoice(relPath);
if (existing.invoice_number) {
await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
}
await logAudit({
@@ -228,12 +232,10 @@ export default async function invoicesRoutes(
const file = nasFinancialsManager.readIssuedInvoice(relPath);
if (!file) return error(reply, "PDF soubor nenalezen", 404);
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
return reply
.type("application/pdf")
.header(
"Content-Disposition",
`inline; filename="${invoice.invoice_number}.pdf"`,
)
.header("Content-Disposition", `inline; filename="${safeName}.pdf"`)
.send(file.data);
},
);

View File

@@ -241,7 +241,20 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8;
try {
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
if (attendanceCreates.length > 0) {
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({
request,
@@ -331,6 +357,7 @@ export default async function leaveRequestsRoutes(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply);
if (id === null) return;
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);
}
if (existing.user_id !== authData.userId) {
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
}
await prisma.leave_requests.update({
where: { id },
data: { status: "cancelled" },

View File

@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
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 {
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="#"');
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {
@@ -102,7 +109,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
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 as Record<string, unknown>).fields) {
cfData =
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
"/:id",
{ preHandler: requirePermission("offers.view") },
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>;
try {
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
@@ -761,29 +774,25 @@ ${indentCSS}
</body>
</html>`;
const saveMode = query.save === "1";
// Save PDF to NAS
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at
? new Date(quotation.created_at)
: new Date();
const saveMode = query.save === "1";
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
const pdfBuffer = await htmlToPdf(html);
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
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.type("text/html").send(html);
} catch (err) {

View File

@@ -3,6 +3,31 @@ 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 ─────────────────────────────────────────────────────── */
@@ -46,6 +71,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
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;
@@ -74,7 +100,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
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 as Record<string, unknown>).fields) {
cfData =
@@ -213,8 +244,13 @@ export default async function ordersPdfRoutes(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body || {};
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];
@@ -250,7 +286,8 @@ export default async function ordersPdfRoutes(
}
const currency = order.currency || "CZK";
const applyVat = !!order.apply_vat;
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
@@ -265,14 +302,13 @@ export default async function ordersPdfRoutes(
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
items = customItemsRaw.map((it: Record<string, unknown>) => ({
description: String(it.description || ""),
quantity: Number(it.quantity) || 0,
unit: String(it.unit || ""),
unit_price: Number(it.unit_price) || 0,
is_included_in_total:
it.is_included_in_total !== false && it.is_included_in_total !== 0,
vat_rate: Number(it.vat_rate) || orderVatRate,
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) => ({
@@ -347,7 +383,9 @@ export default async function ordersPdfRoutes(
})
.join("");
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
let vatDetailHtml = "";
if (applyVat) {
@@ -368,7 +406,7 @@ export default async function ordersPdfRoutes(
? `
<div class="invoice-notes">
<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>
`
: "";
@@ -852,6 +890,13 @@ ${indentCSS}
.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 { requirePermission } from "../../middleware/auth";
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 { parseBody } from "../../schemas/common";
import { config } from "../../config/env";
import {
CreateOrderFromQuotationSchema,
CreateOrderSchema,
@@ -25,7 +26,9 @@ import multipart from "@fastify/multipart";
export default async function ordersRoutes(
fastify: FastifyInstance,
): 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
fastify.get(
@@ -54,15 +57,11 @@ export default async function ordersRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, result.page, result.limit),
);
},
);
@@ -88,12 +87,10 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply
.type("application/pdf")
.header(
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
.send(attachment.data);
},
);
@@ -209,6 +206,7 @@ export default async function ordersRoutes(
const body = manualParsed.data;
const result = await createOrder(body);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({
request,

View File

@@ -38,12 +38,7 @@ export default async function profileRoutes(
const data: Record<string, unknown> = {};
if (body.email) {
const newEmail = 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;
data.email = String(body.email).trim();
}
if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name);
@@ -63,18 +58,42 @@ export default async function profileRoutes(
config.security.bcryptCost,
);
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({
request,
authData: request.authData,
action: "password_change",
action: "update",
entityType: "user",
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");
});
}

View File

@@ -1,6 +1,7 @@
import fs from "fs";
import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import { z } from "zod";
import prisma from "../../config/database";
import { config } from "../../config/env";
import { requirePermission } from "../../middleware/auth";
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response";
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(
fastify: FastifyInstance,
): Promise<void> {
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.view") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
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);
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);
}
const subPath = query.path || "";
if (query.action === "download") {
if (parsedQuery.data.action === "download") {
if (!subPath) return error(reply, "Cesta k souboru je povinná");
if (!project.project_number)
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);
const stream = fs.createReadStream(result.filePath);
const encodedName = encodeURIComponent(result.fileName);
return reply
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
`attachment; filename*=UTF-8''${encodedName}`,
)
.header("Content-Type", result.mime)
.header("X-Content-Type-Options", "nosniff")
@@ -81,8 +105,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -105,7 +130,11 @@ export default async function projectFilesRoutes(
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);
await logAudit({
@@ -130,8 +159,9 @@ export default async function projectFilesRoutes(
bodyLimit: config.nas.maxUploadSize,
},
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -149,14 +179,14 @@ export default async function projectFilesRoutes(
const file = await request.file();
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = query.path || "";
const fileBuffer = await file.toBuffer();
const fileName = file.filename;
const subPath = parsedQuery.data.path || "";
const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const err = await fm.uploadFile(
project.project_number,
subPath,
fileBuffer,
file.file,
fileName,
);
if (err !== null) return error(reply, err);
@@ -180,8 +210,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -198,7 +229,7 @@ export default async function projectFilesRoutes(
if (!fromPath || !toPath)
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);
await logAudit({
@@ -221,8 +252,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -232,7 +264,7 @@ export default async function projectFilesRoutes(
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á");
const err = await fm.deleteItem(project.project_number, filePath);

View File

@@ -69,6 +69,9 @@ export default async function projectsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const project = await createProject(parsed.data);
if ("error" in project) {
return error(reply, project.error, (project as any).status ?? 400);
}
await logAudit({
request,
@@ -126,6 +129,9 @@ export default async function projectsRoutes(
lastName: authData.lastName,
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");
},

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
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 { parseBody } from "../../schemas/common";
import {
@@ -44,11 +44,11 @@ export default async function quotationsRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(result.total, page, limit),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);
@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data);
if ("error" in quotation)
return error(reply, quotation.error!, quotation.status!);
await logAudit({
request,
@@ -312,9 +314,13 @@ export default async function quotationsRoutes(
// Delete PDF from NAS
if (existing.quotation_number && existing.created_at) {
const yr = new Date(existing.created_at).getFullYear();
nasOffersManager.deleteOfferPdf(
try {
await nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
);
} catch {
// Non-fatal: NAS delete may fail if file does not exist
}
}
await logAudit({

View File

@@ -13,6 +13,7 @@ import {
} from "../../schemas/received-invoices.schema";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -98,7 +99,7 @@ export default async function receivedInvoicesRoutes(
// Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = (
invs: typeof monthInvoices,
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
const map: Record<string, number> = {};
@@ -115,7 +116,7 @@ export default async function receivedInvoicesRoutes(
};
const sumCzk = async (
invs: typeof monthInvoices,
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
let total = 0;
@@ -126,9 +127,13 @@ export default async function receivedInvoicesRoutes(
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({
where: { status: { not: "paid" } },
select: { amount: true, currency: true },
});
return success(reply, {
@@ -138,7 +143,7 @@ export default async function receivedInvoicesRoutes(
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: allUnpaid.length,
unpaid_count: unpaidCount,
month_count: monthInvoices.length,
});
},
@@ -188,12 +193,10 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
return reply
.type(mime)
.header(
"Content-Disposition",
`inline; filename="${invoice.file_name}"`,
)
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
.send(nasFile.data);
},
);
@@ -263,10 +266,10 @@ export default async function receivedInvoicesRoutes(
const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0);
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 =
vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0;
const issueDate = meta.issue_date
@@ -315,7 +318,9 @@ export default async function receivedInvoicesRoutes(
status: "unpaid",
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_name: file.name,
file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime,
file_size: file.size,
},
@@ -364,7 +369,7 @@ export default async function receivedInvoicesRoutes(
vat_rate: vatRate,
vat_amount:
vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0,
issue_date: body.issue_date
? new Date(String(body.issue_date))
@@ -434,13 +439,9 @@ export default async function receivedInvoicesRoutes(
body.vat_rate !== undefined
? Number(body.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 =
finalVatRate > 0
? roundMoney(
finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)),
)
: 0;
finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0;
// Auto-set paid_date when status transitions to paid (matching PHP)
const newStatus =
@@ -548,6 +549,9 @@ export default async function receivedInvoicesRoutes(
});
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) {
const relPath = nasFinancialsManager.buildReceivedPath(
existing.file_name,
@@ -556,8 +560,6 @@ export default async function receivedInvoicesRoutes(
);
nasFinancialsManager.deleteReceivedInvoice(relPath);
}
await prisma.received_invoices.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response";
import { encrypt } from "../../utils/encryption";
import { getSystemSettings } from "../../services/system-settings";
import { config } from "../../config/env";
import { OTPAuth } from "../../utils/totp";
import * as OTPAuthLib from "otpauth";
import { logAudit } from "../../services/audit";
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(
fastify: FastifyInstance,
@@ -47,21 +53,45 @@ export default async function totpRoutes(
"/enable",
{ preHandler: requireAuth, bodyLimit: 10240 },
async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { secret, code } = body;
const parsed = parseBody(TotpEnableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { secret, code, password, current_code } = parsed.data;
if (!secret || !code) {
return error(reply, "Secret a kód jsou povinné", 400);
const user = await prisma.users.findUnique({
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({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1",
digits: 6,
period: 30,
});
const delta = totp.validate({ token: String(code), window: 1 });
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
return error(reply, "Neplatný TOTP kód", 400);
}
@@ -70,9 +100,11 @@ export default async function totpRoutes(
const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(code);
backupCodesHashed.push(bcrypt.hashSync(code, 10));
const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(plainCode);
backupCodesHashed.push(
await bcrypt.hash(plainCode, config.security.bcryptCost),
);
}
const encryptedSecret = encrypt(String(secret));
@@ -107,11 +139,9 @@ export default async function totpRoutes(
"/disable",
{ preHandler: requireAuth },
async (request, reply) => {
const body = request.body as Record<string, unknown>;
if (!body.code) {
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
}
const parsed = parseBody(TotpDisableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { code } = parsed.data;
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
@@ -120,8 +150,8 @@ export default async function totpRoutes(
return error(reply, "2FA není aktivní", 400);
}
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
if (!isValid) {
const verifyResult = OTPAuth.verify(user.totp_secret, code);
if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 400);
}
@@ -181,14 +211,29 @@ export default async function totpRoutes(
bodyLimit: 10240,
},
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({
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
? "2FA je nyní povinné pro všechny uživatele"
: "2FA již není povinné";
@@ -200,7 +245,15 @@ export default async function totpRoutes(
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post(
"/backup-verify",
{ bodyLimit: 10240 },
{
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -211,21 +264,39 @@ export default async function totpRoutes(
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
const settings = await getSystemSettings();
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()) {
return error(reply, "Neplatný nebo expirovaný login token", 401);
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
// $queryRaw on MySQL may return BigInt for integer columns
const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id);
const user = await tx.users.findUnique({
where: { id: storedUserId },
include: { roles: true },
});
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(
@@ -237,16 +308,39 @@ export default async function totpRoutes(
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
}
}
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);
await prisma.users.update({
await tx.users.update({
where: { id: user.id },
data: {
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)
const { loadAuthData } = await import("../../services/auth");
@@ -305,6 +406,14 @@ export default async function totpRoutes(
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 });
},
);

View File

@@ -38,9 +38,15 @@ export default async function tripsRoutes(
mo = NaN;
}
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 = {
gte: new Date(yr, mo - 1, 1),
lt: new Date(yr, mo, 1),
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
@@ -75,17 +81,10 @@ export default async function tripsRoutes(
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "trips.record" } },
},
},
],
},
},
},
select: {
id: true,
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId;
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 = {
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
lt: new Date(Number(query.year), Number(query.month), 1),
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
@@ -175,7 +182,7 @@ export default async function tripsRoutes(
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId",
{ preHandler: requireAuth },
{ preHandler: requirePermission("trips.view") },
async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
@@ -206,6 +213,10 @@ export default async function tripsRoutes(
const body = parsed.data;
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({
data: {
vehicle_id: Number(body.vehicle_id),
@@ -247,6 +258,18 @@ export default async function tripsRoutes(
const body = parsed.data;
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 } });
if (!existing) return error(reply, "Jízda nenalezena", 404);

View File

@@ -1,7 +1,8 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
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 { parseBody } from "../../schemas/common";
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 result = await listUsers(params);
return reply.send({
success: true,
data: result.users,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
});
return paginated(
reply,
result.users,
buildPaginationMeta(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);
const body = parsed.data;
const result = await createUser({
const result = await createUser(
{
username: body.username,
email: body.email,
password: body.password,
@@ -67,7 +65,9 @@ export default async function usersRoutes(
last_name: body.last_name,
role_id: body.role_id,
is_active: body.is_active,
});
},
request.authData?.roleName ?? undefined,
);
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)
: (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 (parsed.data.password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: id, replaced_at: null },
data: { replaced_at: new Date() },
});
}
await logAudit({
request,
authData: request.authData,

View File

@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
const existing = await prisma.vehicles.findUnique({ where: { id } });
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 logAudit({
request,

View File

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

View File

@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
export const TotpVerifySchema = z.object({
login_token: z.string().min(1, "Token je povinný"),
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({
@@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({
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 TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

View File

@@ -13,7 +13,7 @@ export const CreateCustomerSchema = 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(),
city: z.string().nullish(),
postal_code: z.string().nullish(),

View File

@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
description: z.string().nullish(),
quantity: z
.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()
.default(1),
unit: z.string().nullish(),
unit_price: z
.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()
.default(0),
vat_rate: z
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().nullish(),
bank_account: 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
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -1,14 +1,18 @@
import { z } from "zod";
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_to: z.string().min(1, "Datum do je povinné"),
notes: z.string().nullish(),
});
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(),
});

View File

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

View File

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

View File

@@ -34,10 +34,22 @@ export const UpdateProjectSchema = z.object({
name: z.string().nullish(),
status: z.string().optional(),
notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(),
quotation_id: z.union([z.number(), z.string(), z.null()]).optional(),
order_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(),
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(),
end_date: z.union([z.string(), z.null()]).optional(),
});

View File

@@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({
.default(0),
issue_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(),
});
@@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({
issue_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(),
status: z.string().optional(),
status: z.enum(["unpaid", "paid"]).optional(),
notes: z.string().nullish(),
month: z
.union([z.number(), z.string()])

View File

@@ -2,6 +2,8 @@ import { z } from "zod";
export const CreateTripSchema = z.object({
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
.union([z.number(), z.string()])
.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ů"),
first_name: z.string().min(1, "Jméno 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
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
),
first_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
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(),

View File

@@ -1,4 +1,5 @@
import Fastify from "fastify";
import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
@@ -36,13 +37,14 @@ const app = Fastify({
logger: {
level: config.isProduction ? "warn" : "info",
},
trustProxy: config.isProduction
? ["127.0.0.1", "192.168.50.100"]
: ["127.0.0.1", "::1"],
bodyLimit: 1048576,
trustProxy:
config.trustProxy.length > 0 ? config.trustProxy : ["127.0.0.1", "::1"],
bodyLimit: config.nas.maxUploadSize,
});
async function start() {
let invoiceAlertCron: ScheduledTask | null = null;
// --- Plugins ---
await app.register(cors, {
origin:
@@ -184,7 +186,7 @@ async function start() {
// --- Invoice alert cron (daily at 8:00 AM) ---
if (config.email.invoiceAlert) {
const cron = await import("node-cron");
cron.default.schedule("0 8 * * *", async () => {
invoiceAlertCron = cron.default.schedule("0 8 * * *", async () => {
try {
const { checkInvoiceAlerts } =
await import("./services/invoice-alerts");
@@ -209,6 +211,9 @@ async function start() {
const shutdown = async (signal: string) => {
app.log.info(`${signal} received, shutting down gracefully...`);
try {
if (invoiceAlertCron) {
invoiceAlertCron.stop();
}
await app.close();
const { default: prisma } = await import("./config/database");
await prisma.$disconnect();

View File

@@ -4,23 +4,16 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
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() {
return prisma.users.findMany({
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
],
},
},
},
select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: "asc" },
@@ -73,11 +66,13 @@ function calcWorkedHours(
}
const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
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 holidayDays = monthRecords.filter(
(r) => (r.leave_type as string) === "holiday",
).length;
const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8);
const adjustedFund = Math.max(0, fund);
const leaveHours = vacationHours + sickHours;
const covered = worked + leaveHours;
const remaining = Math.max(0, adjustedFund - covered);
@@ -266,7 +258,7 @@ export async function getStatus(userId: number) {
const monthlyFund = {
month_name: `${MONTH_NAMES[m]} ${y}`,
fund: adjustedFund,
business_days: workingDays - holidayDays,
business_days: workingDays,
worked,
covered,
remaining,
@@ -389,8 +381,18 @@ export async function updateAddress(
address: string | null,
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({
where: { user_id: userId },
where,
orderBy: { created_at: "desc" },
});
if (!latest) return { error: "Nenalezen záznam" };
@@ -516,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<
string,
{
@@ -552,6 +548,19 @@ export async function getWorkfund(year: number) {
const fundToDate = bizDaysToDate * 8;
const monthStart = new Date(year, m, 1);
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<
string,
@@ -565,16 +574,11 @@ export async function getWorkfund(year: number) {
> = {};
for (const u of users) {
const recs = allRecords.filter(
(r) =>
r.user_id === u.id &&
r.shift_date >= monthStart &&
r.shift_date <= monthEnd,
);
const recs = monthRecords.filter((r) => r.user_id === u.id);
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayDays = 0;
let holidayHours = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
@@ -592,13 +596,13 @@ export async function getWorkfund(year: number) {
} else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") {
holidayDays++;
holidayHours += Number(rec.leave_hours) || 8;
}
}
const userFund = fundToDate;
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 missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
@@ -1140,6 +1144,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
let inserted = 0;
let skipped = 0;
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
@@ -1153,10 +1158,10 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
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)) {
await prisma.attendance.create({
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
@@ -1168,7 +1173,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
continue;
}
await prisma.attendance.create({
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
@@ -1182,6 +1187,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
inserted++;
}
}
});
let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
@@ -1209,22 +1215,28 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const end = new Date(dateTo);
let created = 0;
try {
await prisma.$transaction(async (tx) => {
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
Date.UTC(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
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: {
user_id: userId,
shift_date: shiftDate,
@@ -1245,21 +1257,22 @@ export async function createLeave(data: LeaveData, authUserId: number) {
totalLeaveHours > 0
) {
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 },
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await prisma.leave_balances.update({
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours,
[updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await prisma.leave_balances.create({
await tx.leave_balances.create({
data: {
user_id: userId,
year,
@@ -1270,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` };
}
@@ -1281,7 +1304,7 @@ export async function punchAction(userId: number, data: PunchData) {
const y = now.getFullYear(),
m = now.getMonth(),
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 =
data.latitude != null && data.latitude !== ""
@@ -1411,8 +1434,17 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
const msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
let msRound = settings.clock_rounding_minutes * 60 * 1000;
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(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);
@@ -1438,10 +1470,36 @@ export async function createAttendance(
data: CreateAttendanceData,
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({
data: {
user_id: data.user_id ?? authUserId,
shift_date: new Date(data.shift_date),
user_id: userId,
shift_date: shiftDate,
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
arrival_lat: data.arrival_lat ?? null,
arrival_lng: data.arrival_lng ?? null,

View File

@@ -54,6 +54,16 @@ export async function logAudit(params: {
},
});
} 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.locked_until && new Date(user.locked_until) > new Date())
return null;
const isAdmin = user.roles?.name === "admin";
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(
(rp: { permissions: { name: string } }) => rp.permissions.name,
);
@@ -109,32 +114,47 @@ export async function login(
}
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()) {
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
request.log.warn(`Login failed for locked user: ${username}`);
return {
type: "error",
message: "Účet je dočasně uzamčen. Zkuste to později.",
status: 429,
message: "Neplatné přihlašovací údaje",
status: 401,
};
}
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1;
const updateData: Record<string, unknown> = {
failed_login_attempts: attempts,
};
await prisma.$transaction(async (tx) => {
const updated = await tx.users.update({
where: { id: user.id },
data: { failed_login_attempts: { increment: 1 } },
select: { failed_login_attempts: true },
});
if (attempts >= settings.max_login_attempts) {
updateData.locked_until = new Date(
if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) {
await tx.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
);
),
},
});
}
});
await prisma.users.update({ where: { id: user.id }, data: updateData });
return {
type: "error",
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) {
const loginToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(loginToken);
@@ -159,7 +190,9 @@ export async function login(
data: {
user_id: user.id,
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 storedToken = await prisma.refresh_tokens.findUnique({
where: { token_hash: tokenHash },
});
return prisma.$transaction(async (tx) => {
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 (
!storedToken ||
storedToken.replaced_at ||
storedToken.replaced_by_hash ||
new Date(storedToken.expires_at) < new Date()
) {
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) {
return { type: "error", message: "Uživatel nenalezen", status: 401 };
}
@@ -246,22 +295,23 @@ export async function refreshAccessToken(
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([
prisma.refresh_tokens.update({
where: { id: storedToken.id },
// $queryRaw on MySQL returns 0/1 for booleans; Prisma expects true/false
const rememberMe = Boolean(storedToken.remember_me);
await tx.refresh_tokens.update({
where: { id: storedTokenId },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}),
prisma.refresh_tokens.create({
});
await tx.refresh_tokens.create({
data: {
user_id: storedToken.user_id,
user_id: storedUserId,
token_hash: newRefreshTokenHash,
expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: storedToken.remember_me,
remember_me: rememberMe,
ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null,
},
}),
]);
});
const accessToken = generateAccessToken({
id: authData.userId,
@@ -274,31 +324,18 @@ export async function refreshAccessToken(
accessToken,
refreshToken: newRefreshTokenRaw,
user: authData,
rememberMe: storedToken.remember_me ?? false,
rememberMe: rememberMe,
};
});
}
export async function logout(refreshTokenRaw: string): Promise<void> {
const tokenHash = hashToken(refreshTokenRaw);
const token = await prisma.refresh_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (token) {
// 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
// Delete only the specific token presented, not all sessions
await prisma.refresh_tokens.deleteMany({
where: { token_hash: tokenHash },
});
}
await prisma.refresh_tokens.deleteMany({
where: { expires_at: { lt: new Date() } },
@@ -309,12 +346,12 @@ export async function verifyAccessToken(
token: string,
): Promise<AuthData | null> {
try {
const payload = jwt.verify(
token,
config.jwt.secret,
) as unknown as JwtPayload;
const payload = jwt.verify(token, config.jwt.secret, {
algorithms: ["HS256"],
}) as unknown as JwtPayload;
return loadAuthData(payload.sub);
} catch {
} catch (err) {
console.error("JWT verification error:", err);
return null;
}
}

View File

@@ -10,14 +10,22 @@ interface CnbRate {
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(
date?: string,
): Promise<Record<string, number>> {
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 {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`;
@@ -32,13 +40,20 @@ async function fetchRatesForDate(
rates[r.currencyCode] = r.rate / r.amount;
}
rateCache[key] = rates;
rateCache.set(key, rates);
rateCacheTime = Date.now();
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"];
return { CZK: 1, EUR: 25, USD: 22, GBP: 28 };
if (rateCache.has("today")) return rateCache.get("today")!;
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 */
@@ -50,7 +65,7 @@ export async function toCzk(
if (currency === "CZK") return amount;
const rates = await fetchRatesForDate(date);
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;
}
@@ -61,5 +76,7 @@ export async function getRate(
): Promise<number> {
if (currency === "CZK") return 1;
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;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today);
const in3days = new Date(today);
in3days.setDate(in3days.getDate() + 3);
@@ -48,11 +49,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const createdInvoices = await prisma.invoices.findMany({
where: {
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 } },
invoice_items: true,
invoice_items: { select: { quantity: true, unit_price: true } },
},
});
@@ -105,7 +110,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const receivedInvoices = await prisma.received_invoices.findMany({
where: {
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,
},
});

View File

@@ -55,10 +55,14 @@ function computeInvoiceTotals(
const vatAmount = applyVat
? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return (
s +
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100)
);
const vatRate =
i.vat_rate != null && i.vat_rate !== ""
? Number(i.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21;
const vat = base * (vatRate / 100);
return s + Math.round(vat * 100) / 100;
}, 0)
: 0;
return {
@@ -156,6 +160,40 @@ export {
previewInvoiceNumber as getNextInvoiceNumberPreview,
} from "./numbering.service";
export function invoiceTotalWithVat(inv: {
apply_vat: boolean | null;
vat_rate: { toNumber(): number } | null;
currency: string | null;
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
const sub = inv.invoice_items.reduce(
(s, i) =>
s +
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
0,
);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base =
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0);
const lineVat =
base *
((Number(i.vat_rate?.toNumber()) ||
Number(inv.vat_rate?.toNumber()) ||
21) /
100);
return s + Math.round(lineVat * 100) / 100;
}, 0)
: 0;
return sub + vat;
}
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const now = new Date();
const year = queryYear || now.getFullYear();
@@ -163,29 +201,35 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
const allInvoices = await prisma.invoices.findMany({
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lte: monthEnd },
},
include: { invoice_items: true },
});
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lte: endOfYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lte: endOfYear },
},
include: { invoice_items: true },
}),
]);
const invoiceTotalWithVat = (inv: (typeof allInvoices)[0]) => {
const sub = inv.invoice_items.reduce(
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return (
s +
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100)
);
}, 0)
: 0;
return sub + vat;
};
const aggregateByCurrency = (invoices: typeof allInvoices) => {
const aggregateByCurrency = (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
const map: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || "CZK";
@@ -199,7 +243,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
}));
};
const sumCzk = async (invoices: typeof allInvoices) => {
const sumCzk = async (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
let total = 0;
for (const inv of invoices) {
const amount = invoiceTotalWithVat(inv);
@@ -208,14 +254,7 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
return Math.round(total * 100) / 100;
};
const monthInvoices = allInvoices.filter((inv) => {
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
});
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
const awaitingInvoices = allInvoices.filter((i) => i.status === "issued");
const overdueInvoices = allInvoices.filter((i) => i.status === "overdue");
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
@@ -235,9 +274,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
amount: Math.round(amount * 100) / 100,
currency,
}));
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {
@@ -312,7 +348,7 @@ export async function createInvoice(body: Record<string, any>) {
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : "issued",
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null,
constant_symbol: body.constant_symbol
@@ -454,8 +490,8 @@ export async function deleteInvoice(id: number) {
await prisma.invoices.delete({ where: { id } });
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
const year = existing.invoice_number
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
: new Date().getFullYear();
await releaseInvoiceNumber(year);

View File

@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date";
@@ -294,21 +295,21 @@ export class NasFileManager {
public async uploadFile(
projectNumber: string,
subPath: string,
fileBuffer: Buffer,
fileStream: NodeJS.ReadableStream,
fileName: string,
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (
dirPath === null ||
!fs.existsSync(dirPath) ||
!fs.statSync(dirPath).isDirectory()
) {
if (dirPath === null) {
return "Cílová složka neexistuje";
}
if (fileBuffer.length > config.nas.maxUploadSize) {
const maxMb = Math.round(config.nas.maxUploadSize / 1048576);
return `Soubor je příliš velký (max ${maxMb} MB)`;
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
} catch {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName);
@@ -322,30 +323,61 @@ export class NasFileManager {
return "Tento typ souboru není povolen";
}
const tempPath = path.join(
require("os").tmpdir(),
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
const typeResult = await FileType.fromBuffer(fileBuffer);
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
}
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + "/" + safeName;
if (fs.existsSync(destPath)) {
const base = path.basename(safeName, ext ? "." + ext : "");
let counter = 1;
// Attempt atomic rename; if destination exists, append counter
let renamed = false;
let attempts = 0;
const maxAttempts = 1000;
do {
safeName = base + "_" + counter + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
counter++;
} while (fs.existsSync(destPath));
}
try {
fs.writeFileSync(destPath, fileBuffer);
} catch {
await fs.promises.rename(tempPath, destPath);
renamed = true;
break;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") {
const base = path.basename(safeName, ext ? "." + ext : "");
attempts++;
safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
} else {
break;
}
}
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
@@ -381,7 +413,12 @@ export class NasFileManager {
projectNumber: string,
filePath: string,
): Promise<string | null> {
if (filePath === "" || filePath === "/") {
if (
filePath === "" ||
filePath === "/" ||
filePath === "." ||
filePath === "./"
) {
return "Nelze smazat kořenovou složku projektu";
}
@@ -390,22 +427,19 @@ export class NasFileManager {
return "Neplatná cesta";
}
if (!fs.existsSync(fullPath)) {
return "Soubor nebo složka neexistuje";
}
let isDir: boolean;
try {
isDir = fs.lstatSync(fullPath).isDirectory();
const stat = await fs.promises.lstat(fullPath);
isDir = stat.isDirectory();
} catch {
return "Neplatná cesta";
return "Soubor nebo složka neexistuje";
}
try {
if (isDir) {
await fs.promises.rm(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
await fs.promises.unlink(fullPath);
}
} catch {
return isDir
@@ -416,12 +450,17 @@ export class NasFileManager {
return null;
}
public moveItem(
public async moveItem(
projectNumber: string,
fromPath: string,
toPath: string,
): string | null {
if (fromPath === "" || fromPath === "/") {
): Promise<string | null> {
if (
fromPath === "" ||
fromPath === "/" ||
fromPath === "." ||
fromPath === "./"
) {
return "Nelze přesunout kořenovou složku";
}
@@ -432,7 +471,9 @@ export class NasFileManager {
return "Neplatná cesta";
}
if (!fs.existsSync(fullFrom)) {
try {
await fs.promises.stat(fullFrom);
} catch {
return "Zdrojový soubor neexistuje";
}
@@ -441,8 +482,13 @@ export class NasFileManager {
fullFrom.replace(/\\/g, "/").toLowerCase() ===
fullTo.replace(/\\/g, "/").toLowerCase();
if (fs.existsSync(fullTo) && !sameFile) {
if (!sameFile) {
try {
await fs.promises.stat(fullTo);
return "Cílový soubor již existuje";
} catch {
// target does not exist, continue
}
}
const targetName = path.basename(toPath);
@@ -451,7 +497,7 @@ export class NasFileManager {
}
try {
fs.renameSync(fullFrom, fullTo);
await fs.promises.rename(fullFrom, fullTo);
} catch (err: unknown) {
if (
err instanceof Error &&
@@ -466,17 +512,22 @@ export class NasFileManager {
return null;
}
public createFolder(
public async createFolder(
projectNumber: string,
subPath: string,
folderName: string,
): string | null {
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (
dirPath === null ||
!fs.existsSync(dirPath) ||
!fs.statSync(dirPath).isDirectory()
) {
if (dirPath === null) {
return "Nadřazená složka neexistuje";
}
try {
const stat = await fs.promises.lstat(dirPath);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
return "Nadřazená složka neexistuje";
}
} catch {
return "Nadřazená složka neexistuje";
}
@@ -486,12 +537,15 @@ export class NasFileManager {
}
const newPath = dirPath + "/" + safeName;
if (fs.existsSync(newPath)) {
try {
await fs.promises.stat(newPath);
return "Složka s tímto názvem již existuje";
} catch {
// does not exist, continue
}
try {
fs.mkdirSync(newPath, { mode: 0o775 });
await fs.promises.mkdir(newPath, { mode: 0o775 });
} catch {
return "Nepodařilo se vytvořit složku";
}
@@ -572,6 +626,11 @@ export class NasFileManager {
}
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
// Reject explicit current-directory references (defense-in-depth for destructive ops)
if (normalized === "." || normalized === "./") {
return null;
}
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
// Verify candidate is within project folder
@@ -653,7 +712,7 @@ export class NasFileManager {
return Math.round((bytes / 1073741824) * 10) / 10 + " GB";
}
private isSuspiciousMime(mime: string, ext: string): boolean {
private isSuspiciousMime(mime: string): boolean {
if (SUSPICIOUS_MIMES.includes(mime)) {
return true;
}

View File

@@ -68,11 +68,11 @@ class NasFinancialsManager {
if (!dir) return null;
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
const fullPath = path.join(dir, safeName);
const fullPath = this.uniquePath(dir, safeName);
try {
fs.writeFileSync(fullPath, pdfBuffer);
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
} catch {
return null;
}
@@ -125,12 +125,12 @@ class NasFinancialsManager {
let safeName = this.sanitizeFilename(originalName);
if (!safeName) safeName = "file";
const fullPath = path.join(dir, safeName);
const fullPath = this.uniquePath(dir, safeName);
try {
fs.writeFileSync(fullPath, fileBuffer);
return {
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`,
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`,
};
} catch (e) {
return {

View File

@@ -32,8 +32,8 @@ class NasOffersManager {
pdfBuffer: Buffer,
): string | null {
const { prefix, seq } = this.parseParts(quotationNumber);
const folderName = `${prefix}_${seq}`;
const fileName = `${year}_${prefix}_${seq}.pdf`;
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
const dir = this.ensureDir(year, folderName);
if (!dir) return null;
@@ -81,8 +81,8 @@ class NasOffersManager {
/** Build the relative NAS path for a given quotation number + year */
buildRelativePath(quotationNumber: string, year: number): string {
const { prefix, seq } = this.parseParts(quotationNumber);
const folderName = `${prefix}_${seq}`;
const fileName = `${year}_${prefix}_${seq}.pdf`;
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
return `${year}/${folderName}/${fileName}`;
}
@@ -92,8 +92,10 @@ class NasOffersManager {
} {
const parts = quotationNumber.split("/");
return {
prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA",
seq: parts[parts.length - 1],
prefix: this.sanitizeFilename(
parts.length >= 2 ? parts[parts.length - 2] : "NA",
),
seq: this.sanitizeFilename(parts[parts.length - 1]),
};
}

View File

@@ -109,24 +109,18 @@ async function previewNextSequence(
}
/**
* Decrement the sequence counter for a given type/year.
* Called after deleting a document so the number can be reused.
* Release a sequence number back to the pool.
* NOTE: Blindly decrementing can cause duplicate numbers if numbers were
* allocated after this one. Sequence numbers are consumed but not returned
* to the pool — this is intentionally a no-op.
*/
async function releaseSequence(type: string, year: number) {
try {
await prisma.$executeRaw`
UPDATE number_sequences
SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0)
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
} catch (err) {
// Non-fatal: log but don't fail the delete operation
console.error(`releaseSequence failed for ${type}/${year}:`, err);
}
async function releaseSequence(_type: string, _year: number) {
// No-op: decrementing can cause duplicate sequence numbers.
// Sequence numbers are consumed but never returned to the pool.
}
/** Verify a shared number is not already used by an order or project. */
async function isSharedNumberTaken(number: string): Promise<boolean> {
export async function isSharedNumberTaken(number: string): Promise<boolean> {
const [existingOrder, existingProject] = await Promise.all([
prisma.orders.findFirst({ where: { order_number: number } }),
prisma.projects.findFirst({ where: { project_number: number } }),
@@ -143,13 +137,21 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
}
/** Verify an offer/quotation number is not already used. */
async function isOfferNumberTaken(number: string): Promise<boolean> {
export async function isOfferNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.quotations.findFirst({
where: { quotation_number: number },
});
return !!existing;
}
/** Verify an order number is not already used. */
export async function isOrderNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.orders.findFirst({
where: { order_number: number },
});
return !!existing;
}
/**
* Next offer/quotation number (consumes sequence).
* Verifies uniqueness against the quotations table; retries if taken.

View File

@@ -3,6 +3,7 @@ import {
generateOfferNumber,
previewOfferNumber,
releaseOfferNumber,
isOfferNumberTaken,
} from "./numbering.service";
interface QuotationItemInput {
@@ -54,7 +55,9 @@ function enrichQuotation(q: any) {
0,
);
const vatAmount = q.apply_vat
? subtotal * ((Number(q.vat_rate) || 21) / 100)
? subtotal *
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
100)
: 0;
const { quotation_items, scope_sections, ...rest } = q;
return {
@@ -137,20 +140,30 @@ export async function getOffer(id: number) {
}
export async function createOffer(body: Record<string, any>) {
if (body.quotation_number !== undefined && body.quotation_number !== null) {
const taken = await isOfferNumberTaken(String(body.quotation_number));
if (taken) {
return { error: "Číslo nabídky je již použito", status: 400 } as const;
}
}
return prisma.$transaction(async (tx) => {
const quotationNumber =
body.quotation_number !== undefined && body.quotation_number !== null
? String(body.quotation_number)
: await generateOfferNumber();
: await generateOfferNumber(tx);
const quotation = await prisma.quotations.create({
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
valid_until: body.valid_until
? new Date(String(body.valid_until))
: null,
currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active",
@@ -162,7 +175,7 @@ export async function createOffer(body: Record<string, any>) {
});
if (Array.isArray(body.items)) {
await prisma.quotation_items.createMany({
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.id,
description: item.description ?? null,
@@ -177,7 +190,7 @@ export async function createOffer(body: Record<string, any>) {
}
if (Array.isArray(body.sections)) {
await prisma.scope_sections.createMany({
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
@@ -189,6 +202,7 @@ export async function createOffer(body: Record<string, any>) {
}
return quotation;
});
}
export async function updateOffer(id: number, body: Record<string, any>) {
@@ -204,9 +218,7 @@ export async function updateOffer(id: number, body: Record<string, any>) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
}
await prisma.quotations.update({
where: { id },
data: {
const data = {
customer_id:
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
valid_until:
@@ -225,9 +237,7 @@ export async function updateOffer(id: number, body: Record<string, any>) {
body.apply_vat === "1"
: undefined,
exchange_rate:
body.exchange_rate !== undefined
? Number(body.exchange_rate)
: undefined,
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code:
body.project_code !== undefined
@@ -248,11 +258,11 @@ export async function updateOffer(id: number, body: Record<string, any>) {
: null
: undefined,
modified_at: new Date(),
},
});
};
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
await tx.quotations.update({ where: { id }, data });
if (Array.isArray(body.items)) {
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
await tx.quotation_items.createMany({
@@ -281,6 +291,8 @@ export async function updateOffer(id: number, body: Record<string, any>) {
});
}
});
} else {
await prisma.quotations.update({ where: { id }, data });
}
return { id, quotation_number: existing.quotation_number };
@@ -310,9 +322,10 @@ export async function duplicateOffer(id: number) {
});
if (!original) return null;
const nextOfferNumber = await generateOfferNumber();
return prisma.$transaction(async (tx) => {
const nextOfferNumber = await generateOfferNumber(tx);
const copy = await prisma.quotations.create({
const copy = await tx.quotations.create({
data: {
quotation_number: nextOfferNumber,
project_code: original.project_code,
@@ -330,7 +343,7 @@ export async function duplicateOffer(id: number) {
});
if (original.quotation_items.length > 0) {
await prisma.quotation_items.createMany({
await tx.quotation_items.createMany({
data: original.quotation_items.map((item) => ({
quotation_id: copy.id,
description: item.description,
@@ -345,7 +358,7 @@ export async function duplicateOffer(id: number) {
}
if (original.scope_sections.length > 0) {
await prisma.scope_sections.createMany({
await tx.scope_sections.createMany({
data: original.scope_sections.map((s) => ({
quotation_id: copy.id,
title: s.title,
@@ -357,6 +370,7 @@ export async function duplicateOffer(id: number) {
}
return { copy, original };
});
}
export async function invalidateOffer(id: number) {

View File

@@ -3,6 +3,7 @@ import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service";
interface OrderItemInput {
@@ -46,7 +47,9 @@ function enrichOrder(o: any) {
0,
);
const vatAmount = o.apply_vat
? subtotal * ((Number(o.vat_rate) || 21) / 100)
? subtotal *
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
100)
: 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
@@ -96,7 +99,11 @@ export async function listOrders(params: ListOrdersParams) {
order_items: { orderBy: { position: "asc" } },
order_sections: { orderBy: { position: "asc" } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: { select: { id: true, invoice_number: true }, take: 1 },
invoices: {
select: { id: true, invoice_number: true },
take: 1,
orderBy: { id: "desc" },
},
},
}),
prisma.orders.count({ where }),
@@ -121,7 +128,7 @@ export async function getOrder(id: number) {
},
invoices: {
select: { id: true, invoice_number: true, status: true },
take: 1,
orderBy: { id: "desc" },
},
},
});
@@ -285,12 +292,23 @@ interface CreateOrderData {
}
export async function createOrder(body: CreateOrderData) {
try {
return await prisma.$transaction(async (tx) => {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
: await generateSharedNumber();
: await generateSharedNumber(tx);
const order = await prisma.orders.create({
if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) {
throw Object.assign(new Error("Číslo objednávky je již použito"), {
status: 400,
});
}
}
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
@@ -309,7 +327,7 @@ export async function createOrder(body: CreateOrderData) {
});
if (Array.isArray(body.items)) {
await prisma.order_items.createMany({
await tx.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
@@ -324,7 +342,7 @@ export async function createOrder(body: CreateOrderData) {
}
if (Array.isArray(body.sections)) {
await prisma.order_sections.createMany({
await tx.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
@@ -336,6 +354,16 @@ export async function createOrder(body: CreateOrderData) {
}
return { id: order.id, order_number: order.order_number };
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
};
}
throw err;
}
}
interface UpdateOrderData {
@@ -393,7 +421,25 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
data.apply_vat =
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
await prisma.orders.update({ where: { id }, data });
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
return {
error: "Nelze upravit položky dokončené/stornované objednávky",
status: 400,
} as const;
}
if (
body.status !== undefined &&
(String(body.status) === "dokoncena" ||
String(body.status) === "stornovana")
) {
return {
error: "Nelze upravit položky při změně stavu na dokončeno/storno",
status: 400,
} as const;
}
await prisma.$transaction(async (tx) => {
await tx.orders.update({ where: { id }, data });
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
@@ -404,21 +450,13 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
await tx.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
return {
error: "Nelze upravit položky dokončené/stornované objednávky",
status: 400,
} as const;
}
await prisma.$transaction(async (tx) => {
if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({
@@ -447,6 +485,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
});
}
});
} else {
await prisma.orders.update({ where: { id }, data });
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
}
return { data: { id, order_number: existing.order_number } };
@@ -457,38 +513,51 @@ export async function deleteOrder(id: number) {
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
// Fetch linked projects before the transaction for number release later
const linkedProjects = await prisma.projects.findMany({
where: { order_id: id },
select: { id: true, created_at: true },
});
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await prisma.quotations.updateMany({
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
const linkedProjects = await prisma.projects.findMany({
where: { order_id: id },
select: { id: true, created_at: true },
});
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await prisma.project_notes.deleteMany({
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
});
await prisma.projects.deleteMany({ where: { order_id: id } });
await tx.projects.deleteMany({ where: { order_id: id } });
}
await prisma.orders.delete({ where: { id } });
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
const releasedYears = new Set<number>();
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year);
releasedYears.add(year);
// Release the linked project's shared number(s) too
for (const p of linkedProjects) {
const pYear = p.created_at
? new Date(p.created_at).getFullYear()
: new Date().getFullYear();
if (!releasedYears.has(pYear)) {
await releaseSharedNumber(pYear);
releasedYears.add(pYear);
}
}
return { data: { id, order_number: existing.order_number } };

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