Compare commits

54 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Moved all useCallback definitions before the conditional returns.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:24 +02:00
BOHA
cd25cd6ee4 1.4.7 2026-04-02 12:31:51 +02:00
BOHA
967fbba2a4 fix: invoice PDF footer — single line with space for signatures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:31:51 +02:00
BOHA
41fe65c7fc 1.4.6 2026-04-02 12:01:52 +02:00
BOHA
09d345a312 fix: invoice PDF table — numbers 8pt, description column wider (36%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:01:51 +02:00
BOHA
1a13d745f1 1.4.5 2026-04-02 11:56:06 +02:00
BOHA
ce184771a6 feat: invoice PDF redesign — professional table-based layout
- Header with red accent border, larger invoice number
- Address blocks in connected table grid with equal heights
- Customer and bank info highlighted with gray background
- Bank info uses same row layout as dates (aligned labels/values)
- Labels nowrap, values right-aligned
- Item font size 8pt, table header border gray
- Removed duplicate separator lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:56:05 +02:00
BOHA
7b6365f6b3 1.4.4 2026-04-02 11:28:13 +02:00
BOHA
44867c79f8 fix: PDF item names bold on Linux — font-weight 500→600
Linux lacks Segoe UI semibold, so weight 500 rendered as regular.
Changed to 600 which maps to bold on both Windows and Linux.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:28:12 +02:00
BOHA
09a9e8c2f0 1.4.3 2026-04-02 11:13:30 +02:00
BOHA
b26a6f40b9 fix: invoice PDF shows unit next to quantity (e.g. 193,50 / ks)
Adjusted column widths to prevent header overlap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:13:29 +02:00
BOHA
40cb5a4d76 1.4.2 2026-04-02 11:05:42 +02:00
BOHA
ecd97ae5a3 fix: bulk attendance fill creates holiday records instead of skipping
Holidays now get leave_type: "holiday" with 8h so they count in fund calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:05:42 +02:00
BOHA
d14e97d7bd 1.4.1 2026-04-02 10:56:26 +02:00
BOHA
ef891f8e01 fix: bulk attendance fill — accept string user_ids, skip holidays
- Schema now accepts both string and number user_ids (frontend sends strings)
- Bulk fill now skips Czech public holidays in addition to weekends

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:56:25 +02:00
BOHA
96ba5d034f 1.4.0 2026-03-28 09:03:06 +01:00
BOHA
2402b7cbc8 fix: "Moje žádosti" page shows only current user's requests
Admins were seeing all requests on their own requests page.
Added mine=1 param to force user_id filter regardless of role.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:03:05 +01:00
BOHA
79b2fa5570 1.3.9 2026-03-28 08:56:14 +01:00
BOHA
35fa172d36 fix: trips admin shows only users with trips.record permission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:56:14 +01:00
BOHA
000a77ccf4 1.3.8 2026-03-27 21:27:16 +01:00
BOHA
ecd9f6a181 chore: fix npm audit vulnerabilities (brace-expansion, fastify, nodemailer, picomatch)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:27:14 +01:00
BOHA
68e6d80903 1.3.7 2026-03-27 17:32:22 +01:00
BOHA
af1b41994c fix: attendance shows only users with attendance.record permission
- Filter attendance admin/balances/workfund to users with attendance.record
  permission or admin role
- New attendance_users API action for user dropdown
- Fix missing prisma import in attendance route
- Fix user edit: empty password no longer blocks save (preprocess to undefined)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:32:22 +01:00
BOHA
9779112066 1.3.6 2026-03-27 13:50:00 +01:00
BOHA
e8d6dc1567 fix: dashboard offers card showing wrong counts
Queried status "converted"/"expired" but actual DB values are
"ordered"/"invalidated". Updated label "Prošlé" → "Zneplatněné".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:50:00 +01:00
BOHA
f9dd49591e 1.3.5 2026-03-27 13:44:54 +01:00
BOHA
8cdf057ab3 feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK
- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:44:53 +01:00
BOHA
a3ef37d0d2 1.3.4 2026-03-27 13:00:46 +01:00
BOHA
e0ea997c24 refactor: split admin.css monolith, standardize CSS architecture
- Split admin.css (3228 lines) into 12 focused files: variables, base,
  forms, buttons, layout, components, tables, skeleton, datepicker,
  filemanager, pagination, responsive
- Extracted shared styles from offers.css and dashboard.css into
  components.css and forms.css (offers-* → admin-* prefix)
- Standardized naming: dash-kpi-* → admin-kpi-*, session-* → dash-session-*,
  rich-editor → admin-rich-editor
- Deleted duplicate offers-tabs (using admin-tabs everywhere)
- Deduplicated DatePicker and FileManager CSS (~360 lines removed)
- Added 16 utility classes to base.css (font sizes, widths, gaps, margins)
- Deleted empty admin.css

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:00:45 +01:00
133 changed files with 11431 additions and 8008 deletions

View File

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

303
CLAUDE.md Normal file
View File

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

557
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,18 @@ import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import "./admin.css"; import "./variables.css";
import "./base.css";
import "./forms.css";
import "./buttons.css";
import "./layout.css";
import "./components.css";
import "./tables.css";
import "./skeleton.css";
import "./datepicker.css";
import "./filemanager.css";
import "./pagination.css";
import "./responsive.css";
import "./login.css"; import "./login.css";
import "./dashboard.css"; import "./dashboard.css";
import "./attendance.css"; import "./attendance.css";
@@ -35,7 +46,6 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders")); const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects")); const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices")); const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
@@ -93,7 +103,6 @@ export default function AdminApp() {
<Route path="orders" element={<Orders />} /> <Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} /> <Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} /> <Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} /> <Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} /> <Route path="invoices/new" element={<InvoiceDetail />} />

File diff suppressed because it is too large Load Diff

420
src/admin/base.css Normal file
View File

@@ -0,0 +1,420 @@
/* ============================================================================
Reset & Base
============================================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
html,
body,
#root {
min-height: 100%;
min-height: 100dvh;
max-width: 100vw;
}
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
overflow-x: hidden;
overscroll-behavior-x: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.admin-sidebar,
.admin-header,
.admin-card,
.admin-modal {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
#root {
overflow-x: hidden;
touch-action: pan-y pinch-zoom;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
h1 {
font-size: clamp(2.5rem, 5vw, 4rem);
}
h2 {
font-size: clamp(2rem, 4vw, 3rem);
}
h3 {
font-size: clamp(1.25rem, 2vw, 1.5rem);
}
p {
color: var(--text-secondary);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
transition: var(--transition);
}
img {
max-width: 100%;
height: auto;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
::selection {
background: var(--accent-color);
color: #fff;
}
/* ============================================================================
Base / Utilities
============================================================================ */
.text-warning {
color: var(--warning) !important;
}
.text-danger {
color: var(--danger) !important;
}
.text-success {
color: var(--success) !important;
}
.text-muted {
color: var(--text-muted) !important;
}
.text-secondary {
color: var(--text-secondary) !important;
}
.text-tertiary {
color: var(--text-tertiary) !important;
}
.text-accent {
color: var(--accent-color) !important;
}
.fw-600 {
font-weight: 600 !important;
}
.link-accent {
color: var(--accent-color);
font-weight: 500;
text-decoration: none;
}
.link-accent:hover {
text-decoration: underline;
}
/* Layout utilities */
.flex-1 {
flex: 1;
}
.flex-row {
display: flex;
align-items: center;
}
.flex-row-gap {
display: flex;
align-items: center;
gap: var(--space-3);
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Spacing utilities */
.mb-2 {
margin-bottom: var(--space-2);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-6 {
margin-top: var(--space-6);
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.gap-5 {
gap: var(--space-5);
}
/* Typography utilities */
.fw-500 {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
/* Spinner variant */
.admin-spinner-sm {
width: 16px;
height: 16px;
border-width: 2px;
}
/* Monospace for data values (times, dates, numbers, IDs) */
.admin-mono {
font-family: var(--font-mono);
font-size: 0.875em;
letter-spacing: -0.01em;
}
/* Drag handle */
.admin-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-muted);
cursor: grab;
border-radius: 4px;
padding: 0;
transition:
color 0.15s,
background 0.15s;
touch-action: none;
}
.admin-drag-handle:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.admin-drag-handle:active {
cursor: grabbing;
}
/* Error stack (DEV only) */
.admin-error-stack {
max-width: 600px;
max-height: 200px;
overflow: auto;
padding: 0.75rem 1rem;
margin: 0;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--danger-color);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
/* Keyboard shortcut badge */
.admin-kbd {
display: inline-block;
padding: 2px 7px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
border-radius: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
white-space: nowrap;
}
/* Loading & Animations */
.admin-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--accent-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.admin-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 256px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(30px, -30px);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */
/* Font sizes */
.text-xs {
font-size: 0.75rem;
}
.text-sm {
font-size: 0.8125rem;
}
.text-md {
font-size: 0.875rem;
}
.text-base {
font-size: 1rem;
}
/* Width utilities */
.w-full {
width: 100%;
}
.max-w-xs {
max-width: 120px;
}
.max-w-sm {
max-width: 200px;
}
/* Whitespace */
.whitespace-nowrap {
white-space: nowrap;
}
/* Additional gaps */
.gap-1 {
gap: 0.25rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-6 {
gap: 1.5rem;
}
/* Additional margins */
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-3 {
margin-top: 0.75rem;
}
/* Display */
.inline-flex {
display: inline-flex;
align-items: center;
}
/* Prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.admin-spinner {
animation-duration: 0.8s !important;
animation-iteration-count: infinite !important;
}
}

130
src/admin/buttons.css Normal file
View File

@@ -0,0 +1,130 @@
/* ============================================================================
Buttons
============================================================================ */
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 8px 14px;
border: none;
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 550;
font-family: inherit;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.admin-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Prevent buttons from growing when text changes to spinner + loading text */
.admin-modal-footer .admin-btn {
min-width: 100px;
}
.admin-modal-footer .admin-btn .admin-spinner {
margin: -2px 0;
}
.admin-btn-sm {
padding: 6px 11px;
font-size: 12px;
}
.admin-btn-primary {
background: var(--accent-color);
color: #fff;
}
.admin-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
}
.admin-btn .admin-spinner {
width: 16px;
height: 16px;
border-width: 2px;
}
.admin-btn-primary .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-secondary .admin-spinner {
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
border-top-color: var(--text-secondary);
}
.admin-btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.admin-btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--border-color-hover);
color: var(--text-primary);
}
.admin-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: var(--transition);
}
.admin-btn-icon:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.admin-btn-icon.accent {
color: var(--info);
}
.admin-btn-icon.accent:hover {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-btn-icon.danger {
color: var(--danger);
}
.admin-btn-icon.danger:hover {
background: var(--danger-light);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-btn {
min-height: 44px;
padding: 10px 16px;
}
.admin-btn-sm {
min-height: 36px;
}
.admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
}

925
src/admin/components.css Normal file
View File

@@ -0,0 +1,925 @@
/* ============================================================================
Cards
============================================================================ */
.admin-card {
background: var(--card-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
overflow: hidden;
margin-bottom: 1rem;
}
.admin-card:last-child {
margin-bottom: 0;
}
.admin-card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
}
.admin-card-title {
font-size: 14px;
font-weight: 650;
color: var(--text-primary);
margin: 0 0 12px 0;
}
.admin-card-body {
padding: 18px;
}
@media (max-width: 480px) {
.admin-card-body {
padding: 12px;
}
.admin-card-header {
padding: 12px;
}
}
/* ============================================================================
Badges
============================================================================ */
.admin-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 3px 9px;
border-radius: 9999px;
font-size: 11.5px;
font-weight: 600;
border: none;
font-family: inherit;
white-space: nowrap;
max-width: 100%;
}
.admin-badge-wrap {
white-space: normal;
word-break: break-word;
border-radius: var(--border-radius-sm);
text-align: left;
}
.admin-badge-admin {
background: var(--accent-soft);
color: var(--accent-color);
}
.admin-badge-viewer {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.admin-badge-active {
background: var(--success-soft);
color: var(--success);
cursor: pointer;
transition: var(--transition);
}
.admin-badge-active:hover {
background: color-mix(in srgb, var(--success) 20%, transparent);
}
.admin-badge-inactive {
background: var(--danger-soft);
color: var(--danger);
cursor: pointer;
transition: var(--transition);
}
.admin-badge-inactive:hover {
background: color-mix(in srgb, var(--danger) 20%, transparent);
}
.admin-badge-success {
background: var(--success-soft);
color: var(--success);
}
.admin-badge-warning {
background: var(--warning-soft);
color: var(--warning);
}
.admin-badge-secondary {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.admin-badge-info {
background: var(--info-soft);
color: var(--info);
}
.admin-badge-danger {
background: var(--danger-soft);
color: var(--danger);
}
/* Status Badges - Leave Requests */
.badge-pending {
background: color-mix(in srgb, var(--warning) 15%, transparent);
color: var(--warning);
}
.badge-approved {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.badge-rejected {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
.badge-cancelled {
background: var(--muted-light);
color: var(--muted);
}
/* Status Badges - Orders */
.admin-badge-order-prijata {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-order-realizace {
background: color-mix(in srgb, var(--warning) 15%, transparent);
color: var(--warning);
}
.admin-badge-order-dokoncena {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-order-stornovana {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* Status Badges - Projects */
.admin-badge-project-aktivni {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-project-dokonceny {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-project-zruseny {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* Badge on mobile - larger for touch */
@media (max-width: 768px) {
.admin-badge {
padding: 4px 10px;
font-size: 12px;
}
button.admin-badge {
min-height: 32px;
}
}
/* ============================================================================
Modals
============================================================================ */
.admin-modal-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
}
.admin-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
touch-action: none;
}
.admin-modal {
position: relative;
width: 100%;
max-width: 480px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
overflow: hidden;
display: flex;
flex-direction: column;
touch-action: auto;
}
.admin-modal-lg {
max-width: 900px;
}
.admin-modal-header {
padding: 18px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.admin-modal-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.admin-modal-body {
padding: 18px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background: var(--bg-primary);
}
.admin-modal-footer {
padding: 14px 18px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
flex-shrink: 0;
}
@media (max-width: 768px) {
.admin-modal-overlay {
padding: 0;
}
.admin-modal,
.admin-modal.admin-modal-lg {
max-width: 100%;
width: 100%;
height: 100%;
height: 100dvh;
max-height: 100%;
max-height: 100dvh;
border-radius: 0;
border: none;
}
.admin-modal-header {
padding: 1rem;
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
}
.admin-modal-body {
padding: 1rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.admin-modal-footer {
padding: 1rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
.admin-modal .admin-form-input,
.admin-modal .admin-form-select,
.admin-modal .admin-form-textarea {
max-width: 100%;
}
}
/* Confirm Modal */
.admin-confirm-modal {
max-width: 400px;
}
.admin-confirm-content {
text-align: center;
padding: 2rem 1.5rem;
}
.admin-confirm-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.25rem;
}
.admin-confirm-icon-danger {
background: var(--danger-light);
color: var(--danger);
}
.admin-confirm-icon-warning {
background: var(--warning-light);
color: var(--warning);
}
.admin-confirm-icon-info {
background: var(--info-light);
color: var(--info);
}
.admin-confirm-icon-default {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.admin-confirm-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.admin-confirm-message {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
@media (max-width: 768px) {
.admin-confirm-modal {
max-width: 100%;
height: auto;
max-height: calc(100% - 2rem);
max-height: calc(100dvh - 2rem);
margin: 1rem;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
.admin-confirm-modal .admin-modal-footer {
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
}
/* Confirm modal on small mobile */
@media (max-width: 480px) {
.admin-confirm-content {
padding: 1.5rem 1rem;
}
.admin-confirm-title {
font-size: 1.1rem;
}
.admin-confirm-message {
font-size: 0.875rem;
}
}
/* ============================================================================
Toast Alerts
============================================================================ */
.admin-alert-container {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
max-width: 400px;
width: calc(100% - 2rem);
pointer-events: none;
transform: translateZ(0);
}
@media (min-width: 640px) {
.admin-alert-container {
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
right: 1.5rem;
}
}
.admin-toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: var(--border-radius-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}
.admin-toast-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.admin-toast-message {
flex: 1;
font-size: 0.875rem;
color: var(--text-primary);
}
.admin-toast-close {
flex-shrink: 0;
padding: 0.25rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-sm);
transition: var(--transition);
}
.admin-toast-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.admin-toast-success .admin-toast-icon {
color: var(--success);
}
.admin-toast-error .admin-toast-icon {
color: var(--danger);
}
.admin-toast-warning .admin-toast-icon {
color: var(--warning);
}
.admin-toast-info .admin-toast-icon {
color: var(--info);
}
/* ============================================================================
Tabs (Global)
============================================================================ */
.admin-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
}
.admin-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition:
color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
letter-spacing: 0.01em;
white-space: nowrap;
}
.admin-tab:hover {
color: var(--text-primary);
}
.admin-tab.active {
color: var(--text-primary);
font-weight: 600;
background: var(--bg-secondary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 1px var(--border-color);
}
/* ============================================================================
Empty State
============================================================================ */
.admin-empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-secondary);
}
.admin-empty-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
}
.admin-empty-state p {
margin-bottom: 1rem;
font-size: 0.95rem;
max-width: 320px;
}
.admin-role-locked-notice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--warning-light);
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
border-radius: 0.5rem;
color: var(--warning);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
/* ============================================================================
Forbidden (403)
============================================================================ */
.forbidden-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
}
.forbidden-icon {
color: var(--accent-color);
margin-bottom: 1.5rem;
opacity: 0.8;
}
.forbidden-title {
font-family: var(--font-heading);
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.75rem;
}
.forbidden-text {
color: var(--text-secondary);
font-size: 1rem;
max-width: 400px;
line-height: 1.6;
margin: 0 0 2rem;
}
.forbidden-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: #fff;
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 600;
transition: var(--transition);
}
.forbidden-link:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
/* ============================================================================
Stat Cards
============================================================================ */
.admin-stat-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
}
.admin-stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent-color);
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.admin-stat-card.success::before {
background: var(--success);
}
.admin-stat-card.warning::before {
background: var(--warning);
}
.admin-stat-card.danger::before {
background: var(--danger);
}
.admin-stat-card.info::before {
background: var(--info);
}
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--accent-soft);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.admin-stat-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.admin-stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
letter-spacing: -0.02em;
line-height: 1.2;
}
.admin-stat-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-stat-footer {
font-size: 0.75rem;
color: var(--text-secondary);
}
.admin-stat-icon.danger {
background: var(--danger-soft);
color: var(--danger);
}
.admin-stat-icon.info {
background: var(--info-soft);
color: var(--info);
}
.admin-stat-icon.success {
background: var(--success-soft);
color: var(--success);
}
.admin-stat-icon.warning {
background: var(--warning-soft);
color: var(--warning);
}
/* ============================================================================
KPI Grid
============================================================================ */
.admin-kpi-grid {
display: grid;
gap: 0.875rem;
}
.admin-kpi-4 {
grid-template-columns: repeat(4, 1fr);
}
.admin-kpi-3 {
grid-template-columns: repeat(3, 1fr);
}
.admin-kpi-2 {
grid-template-columns: repeat(2, 1fr);
}
.admin-kpi-1 {
grid-template-columns: 1fr;
max-width: 320px;
}
@media (max-width: 1024px) {
.admin-kpi-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.admin-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.admin-kpi-grid {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Editor Section Cards
============================================================================ */
.admin-editor-section {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 640px) {
.admin-editor-section {
padding: 1rem;
}
}
/* ============================================================================
Totals Summary
============================================================================ */
.admin-totals-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.admin-totals-row {
display: flex;
gap: 2rem;
justify-content: flex-end;
min-width: 250px;
padding: 0.25rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.admin-totals-row span:last-child {
min-width: 100px;
text-align: right;
font-weight: 500;
color: var(--text-primary);
}
.admin-totals-total {
border-top: 2px solid var(--text-primary);
margin-top: 0.25rem;
padding-top: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.admin-totals-total span:last-child {
font-weight: 700;
}
@media (max-width: 640px) {
.admin-totals-summary {
align-items: stretch;
}
.admin-totals-row {
min-width: unset;
}
}
/* ============================================================================
Scope Sections
============================================================================ */
.admin-scope-list {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-scope-section {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
transition: border-color var(--transition);
background: var(--bg-primary);
}
.admin-scope-content {
overflow: hidden;
}
.admin-scope-section:hover {
border-color: color-mix(
in srgb,
var(--border-color) 70%,
var(--accent-color)
);
}
.admin-scope-section-header {
display: flex;
align-items: center;
padding: 0.625rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
gap: 0.5rem;
}
.admin-scope-section-header .admin-scope-number {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
flex-shrink: 0;
min-width: 1.25rem;
}
.admin-scope-section-header .admin-scope-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.admin-scope-section-header .admin-scope-actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.admin-scope-section .admin-form {
padding: 1rem;
}
/* ============================================================================
Logo Section
============================================================================ */
.admin-logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.admin-logo-preview {
max-width: 200px;
max-height: 100px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: #fff;
}
.admin-logo-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,10 +109,10 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
} }
const KPI_CLASS_MAP: Record<number, string> = { const KPI_CLASS_MAP: Record<number, string> = {
4: "dash-kpi-4", 4: "admin-kpi-4",
3: "dash-kpi-3", 3: "admin-kpi-3",
2: "dash-kpi-2", 2: "admin-kpi-2",
1: "dash-kpi-1", 1: "admin-kpi-1",
}; };
export default function DashKpiCards({ dashData }: DashKpiCardsProps) { export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
@@ -121,11 +121,11 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
return null; return null;
} }
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "dash-kpi-4"; const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
return ( return (
<motion.div <motion.div
className={`dash-kpi-grid ${kpiClass}`} className={`admin-kpi-grid ${kpiClass}`}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}

View File

@@ -218,17 +218,17 @@ export default function DashSessions() {
</div> </div>
)} )}
{!sessionsLoading && sessions.length > 0 && ( {!sessionsLoading && sessions.length > 0 && (
<div className="sessions-list"> <div className="dash-sessions-list">
{sessions.map((session) => ( {sessions.map((session) => (
<div <div
key={session.id} key={session.id}
className={`session-item ${session.is_current ? "session-item-current" : ""}`} className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
> >
<div className="session-icon"> <div className="dash-session-icon">
{getDeviceIcon(session.device_info?.icon)} {getDeviceIcon(session.device_info?.icon)}
</div> </div>
<div className="session-info"> <div className="dash-session-info">
<div className="session-device"> <div className="dash-session-device">
{session.device_info?.browser} na{" "} {session.device_info?.browser} na{" "}
{session.device_info?.os} {session.device_info?.os}
{session.is_current && ( {session.is_current && (
@@ -240,13 +240,13 @@ export default function DashSessions() {
</span> </span>
)} )}
</div> </div>
<div className="session-meta"> <div className="dash-session-meta">
<span>{session.ip_address}</span> <span>{session.ip_address}</span>
<span className="session-meta-separator">|</span> <span className="dash-session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span> <span>{formatSessionDate(session.created_at)}</span>
</div> </div>
</div> </div>
<div className="session-actions"> <div className="dash-session-actions">
{!session.is_current && ( {!session.is_current && (
<button <button
onClick={() => onClick={() =>

View File

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

View File

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

View File

@@ -1,103 +1,3 @@
/* ============================================================================
Stat Cards
============================================================================ */
.admin-stat-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
}
.admin-stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent-color);
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.admin-stat-card.success::before {
background: var(--success);
}
.admin-stat-card.warning::before {
background: var(--warning);
}
.admin-stat-card.danger::before {
background: var(--danger);
}
.admin-stat-card.info::before {
background: var(--info);
}
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--accent-soft);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.admin-stat-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.admin-stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
letter-spacing: -0.02em;
line-height: 1.2;
}
.admin-stat-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-stat-footer {
font-size: 0.75rem;
color: var(--text-secondary);
}
.admin-stat-icon.danger {
background: var(--danger-soft);
color: var(--danger);
}
.admin-stat-icon.info {
background: var(--info-soft);
color: var(--info);
}
.admin-stat-icon.success {
background: var(--success-soft);
color: var(--success);
}
.admin-stat-icon.warning {
background: var(--warning-soft);
color: var(--warning);
}
/* ============================================================================ /* ============================================================================
Dashboard Dashboard
============================================================================ */ ============================================================================ */
@@ -113,26 +13,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* KPI grid */
.dash-kpi-grid {
display: grid;
gap: 0.875rem;
}
.dash-kpi-4 {
grid-template-columns: repeat(4, 1fr);
}
.dash-kpi-3 {
grid-template-columns: repeat(3, 1fr);
}
.dash-kpi-2 {
grid-template-columns: repeat(2, 1fr);
}
.dash-kpi-1 {
grid-template-columns: 1fr;
max-width: 320px;
}
/* Quick actions */ /* Quick actions */
.dash-quick-actions { .dash-quick-actions {
display: grid; display: grid;
@@ -512,16 +392,9 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.dash-kpi-4 {
grid-template-columns: repeat(2, 1fr);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dash-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.dash-quick-actions { .dash-quick-actions {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -543,9 +416,6 @@
.dash-quick-actions { .dash-quick-actions {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.dash-kpi-grid {
grid-template-columns: 1fr;
}
.dash-profile-grid { .dash-profile-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -555,12 +425,12 @@
Sessions / Devices Sessions / Devices
============================================================================ */ ============================================================================ */
.sessions-list { .dash-sessions-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.session-item { .dash-session-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -569,23 +439,23 @@
transition: var(--transition); transition: var(--transition);
} }
.session-item:last-child { .dash-session-item:last-child {
border-bottom: none; border-bottom: none;
} }
.session-item:hover { .dash-session-item:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
.session-item-current { .dash-session-item-current {
background: var(--row-current); background: var(--row-current);
} }
.session-item-current:hover { .dash-session-item-current:hover {
background: var(--row-current-hover); background: var(--row-current-hover);
} }
.session-icon { .dash-session-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
@@ -597,17 +467,17 @@
flex-shrink: 0; flex-shrink: 0;
} }
.session-item-current .session-icon { .dash-session-item-current .dash-session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent); background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success); color: var(--success);
} }
.session-info { .dash-session-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.session-device { .dash-session-device {
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--text-primary);
display: flex; display: flex;
@@ -616,7 +486,7 @@
gap: 0.25rem; gap: 0.25rem;
} }
.session-meta { .dash-session-meta {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--text-muted);
margin-top: 0.25rem; margin-top: 0.25rem;
@@ -626,30 +496,30 @@
gap: 0.5rem; gap: 0.5rem;
} }
.session-meta-separator { .dash-session-meta-separator {
color: var(--border-color); color: var(--border-color);
} }
.session-actions { .dash-session-actions {
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.session-item { .dash-session-item {
padding: 1rem; padding: 1rem;
gap: 0.75rem; gap: 0.75rem;
} }
.session-icon { .dash-session-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
} }
.session-device { .dash-session-device {
font-size: 0.875rem; font-size: 0.875rem;
} }
.session-meta { .dash-session-meta {
font-size: 0.75rem; font-size: 0.75rem;
} }
} }

199
src/admin/datepicker.css Normal file
View File

@@ -0,0 +1,199 @@
/* ============================================================================
React DatePicker Overrides
============================================================================ */
.react-datepicker-wrapper {
width: 100%;
}
.react-datepicker-popper {
z-index: 100 !important;
}
/* Prevent flash at top-left before popper calculates position */
#datepicker-portal .react-datepicker-popper {
opacity: 0;
animation: dp-fade-in 0.01s forwards 0.02s;
}
@keyframes dp-fade-in {
to {
opacity: 1;
}
}
.react-datepicker {
font-family: inherit !important;
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius-sm) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
}
.react-datepicker__triangle {
display: none !important;
}
/* Header */
.react-datepicker__header {
background-color: var(--bg-tertiary) !important;
border-bottom: 1px solid var(--border-color) !important;
padding-top: 0.75rem !important;
}
.react-datepicker__current-month,
.react-datepicker-time__header {
color: var(--text-primary) !important;
font-weight: 600 !important;
}
.react-datepicker__day-name {
color: var(--text-secondary) !important;
}
/* Days */
.react-datepicker__day {
color: var(--text-primary) !important;
border-radius: 6px !important;
transition:
background 0.15s,
color 0.15s !important;
}
.react-datepicker__day:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__day--today {
font-weight: 700 !important;
}
.react-datepicker__day--outside-month {
color: var(--text-muted) !important;
opacity: 0.5;
}
.react-datepicker__day--disabled {
color: var(--text-muted) !important;
opacity: 0.3 !important;
}
/* Navigation arrows */
.react-datepicker__navigation {
top: 0.75rem !important;
}
.react-datepicker__navigation-icon::before {
border-color: var(--text-secondary) !important;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--accent-color) !important;
}
/* Year dropdown */
.react-datepicker__year-dropdown,
.react-datepicker__month-dropdown,
.react-datepicker__year-read-view,
.react-datepicker__month-read-view {
color: var(--text-primary) !important;
}
/* Time picker */
.react-datepicker__time-container {
border-left: 1px solid var(--border-color) !important;
}
.react-datepicker__time-container .react-datepicker__time {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 100% !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item {
color: var(--text-primary) !important;
transition: background 0.15s !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
font-weight: 600 !important;
}
/* Month picker */
.react-datepicker__monthPicker {
background-color: var(--bg-secondary) !important;
}
.react-datepicker-year-header {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-bottom: 1px solid var(--border-color) !important;
}
.react-datepicker__month-wrapper {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__month-text {
color: var(--text-primary) !important;
padding: 0.5rem !important;
border-radius: 6px !important;
transition: background 0.15s !important;
background-color: transparent !important;
}
.react-datepicker__month-text:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__month-text--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__month-text--today {
font-weight: 700 !important;
}
/* Input */
.react-datepicker__input-container input {
cursor: pointer;
}
.react-datepicker__close-icon::after {
background-color: var(--accent-color) !important;
}

171
src/admin/filemanager.css Normal file
View File

@@ -0,0 +1,171 @@
/* ============================================================================
File Manager
============================================================================ */
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.fm-full-path {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
user-select: all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.fm-toolbar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fm-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 12px;
min-height: 28px;
}
.fm-breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.fm-breadcrumb-sep {
color: var(--text-tertiary);
margin: 0 4px;
user-select: none;
}
.fm-breadcrumb-btn {
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
.fm-breadcrumb-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.fm-breadcrumb-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.fm-new-folder {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.fm-new-folder .admin-form-input {
max-width: 250px;
}
.fm-content {
position: relative;
border-radius: var(--border-radius-sm);
transition: border-color 0.2s ease;
}
.fm-content.fm-drag-over {
border: 2px dashed var(--accent-color);
background: var(--accent-light);
}
.fm-dropzone-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
border-radius: var(--border-radius-sm);
z-index: 5;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.fm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
color: var(--text-tertiary);
font-size: 13px;
}
.fm-folder-link {
background: none;
border: none;
padding: 0;
color: var(--accent-color);
font-weight: 500;
font-size: inherit;
font-family: inherit;
cursor: pointer;
}
.fm-folder-link:hover {
text-decoration: underline;
}
.fm-item-count {
font-size: 10px;
color: var(--text-tertiary);
font-weight: 400;
}
.fm-file-name {
color: var(--text-primary);
}
.fm-meta {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.fm-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}
.fm-name-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-symlink-badge {
display: inline-flex;
align-items: center;
color: var(--text-tertiary);
cursor: help;
}

488
src/admin/forms.css Normal file
View File

@@ -0,0 +1,488 @@
/* ============================================================================
Forms
============================================================================ */
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.admin-form-input {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
min-height: 36px;
}
.admin-form-input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-input::placeholder {
color: var(--text-muted);
}
.admin-form-input[type="date"],
.admin-form-input[type="time"],
.admin-form-input[type="month"],
.admin-form-input[type="number"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
text-align: left;
height: 36px;
max-width: 100%;
}
.admin-form-input[type="number"]::-webkit-inner-spin-button,
.admin-form-input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-date-and-time-value,
.admin-form-input[type="time"]::-webkit-date-and-time-value,
.admin-form-input[type="month"]::-webkit-date-and-time-value {
text-align: left;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-datetime-edit,
.admin-form-input[type="time"]::-webkit-datetime-edit,
.admin-form-input[type="month"]::-webkit-datetime-edit {
padding: 0;
}
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
filter: var(--calendar-icon-filter, none);
cursor: pointer;
}
/* Select */
.admin-form-select {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 36px;
box-sizing: border-box;
cursor: pointer;
appearance: none;
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 32px;
}
.admin-form-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Textarea */
.admin-form-textarea {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.admin-form-textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* Checkbox */
.admin-form-checkbox {
display: inline-flex;
align-items: flex-start;
gap: 0;
cursor: pointer;
user-select: none;
}
.admin-form-checkbox input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.admin-form-checkbox input + span::before {
content: "";
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
vertical-align: middle;
transition:
border-color var(--transition),
box-shadow var(--transition),
background var(--transition);
flex-shrink: 0;
}
.admin-form-checkbox input:checked + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:focus + span::before {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-checkbox:hover
input:not(:checked):not(:disabled):not(:indeterminate)
+ span::before {
border-color: var(--border-color-hover);
background: var(--bg-secondary);
}
.admin-form-checkbox input:indeterminate + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:disabled + span::before {
opacity: 0.5;
cursor: not-allowed;
}
.admin-form-checkbox:has(input:disabled) {
cursor: not-allowed;
opacity: 0.7;
}
.admin-form-checkbox span {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* Reorderable List */
.admin-reorder-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-reorder-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.admin-reorder-arrows {
display: flex;
gap: 2px;
}
.admin-reorder-label {
font-size: 13px;
color: var(--text-primary);
}
.admin-reorder-label.accent {
color: var(--accent-color);
}
.admin-reorder-arrows .admin-btn-icon {
width: 22px;
height: 22px;
color: var(--text-muted);
}
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
.admin-reorder-arrows .admin-btn-icon:disabled {
opacity: 0.25;
}
/* Form Rows (Grid Layouts) */
.admin-form-row {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-3 {
grid-template-columns: repeat(3, 1fr);
}
.admin-form-row-4 {
grid-template-columns: repeat(4, 1fr);
}
.admin-form-row-5 {
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
}
@media (max-width: 768px) {
.admin-form-row-4 {
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-5 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.admin-form-row,
.admin-form-row-3 {
grid-template-columns: 1fr;
}
.admin-form-row-5 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.admin-form-row-4,
.admin-form-row-5 {
grid-template-columns: 1fr;
}
}
/* Form Utilities */
.admin-form-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Required field indicator */
.admin-form-label.required::after {
content: " *";
color: var(--danger);
font-weight: 600;
}
/* Inline field errors */
.admin-form-group.has-error .admin-form-input,
.admin-form-group.has-error .admin-form-select,
.admin-form-group.has-error .admin-form-textarea {
border-color: var(--danger);
box-shadow: 0 0 0 3px var(--danger-light);
}
.admin-form-group.has-error .admin-form-label {
color: var(--danger);
}
.admin-form-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.admin-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-form-input,
.admin-form-select,
.admin-form-textarea {
min-height: 44px;
font-size: 16px; /* prevent auto-zoom on iOS */
}
.admin-form-checkbox {
min-height: 44px;
padding: 8px 0;
}
.admin-form-checkbox input + span::before {
width: 20px;
height: 20px;
}
.admin-form-label {
font-size: 13px;
}
}
/* ============================================================================
Customer Selector
============================================================================ */
.admin-customer-select {
position: relative;
}
.admin-customer-selected {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--input-bg);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.admin-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.admin-customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.admin-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.admin-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.admin-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.admin-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.admin-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.admin-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.admin-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}

View File

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

View File

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

View File

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

View File

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

View File

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

582
src/admin/layout.css Normal file
View File

@@ -0,0 +1,582 @@
/* ============================================================================
Layout
============================================================================ */
.admin-layout {
display: flex;
min-height: 100vh;
min-height: 100dvh;
background: var(--bg-primary);
}
@media (min-width: 1024px) {
.admin-layout {
align-items: flex-start;
}
}
/* ============================================================================
Sidebar
============================================================================ */
/* -- Theme variables for sidebar -- */
[data-theme="dark"] .admin-sidebar {
--sb-bg: #141414;
--sb-border: #2a2a2a;
--sb-text: #a0a0a0;
--sb-text-hover: #ddd;
--sb-hover-bg: #1f1f1f;
--sb-active-bg: #ffffff;
--sb-active-text: #141414;
--sb-label: #444;
--sb-muted: #555;
--sb-scrollbar: #333;
}
[data-theme="light"] .admin-sidebar {
--sb-bg: #ffffff;
--sb-border: #e8e6e1;
--sb-text: #7c7c84;
--sb-text-hover: #1a1a1a;
--sb-hover-bg: #f5f4f2;
--sb-active-bg: #141414;
--sb-active-text: #ffffff;
--sb-label: #a0a0a0;
--sb-muted: #a0a0a0;
--sb-scrollbar: #ddd;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 50;
background: var(--sb-bg);
border-right: 1px solid var(--sb-border);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
transform: translateX(-100%);
visibility: hidden;
transition:
transform 0.3s ease,
visibility 0.3s ease;
overflow: hidden;
overscroll-behavior: none;
}
.admin-sidebar.open {
transform: translateX(0);
visibility: visible;
touch-action: none;
}
@media (min-width: 1024px) {
.admin-sidebar {
right: auto;
width: 220px;
height: 100%;
transform: none;
visibility: visible;
padding: 0;
}
}
[data-theme="light"] .admin-sidebar {
box-shadow:
1px 0 0 0 var(--sb-border),
4px 0 16px rgba(0, 0, 0, 0.04);
}
/* Sidebar Overlay (mobile) */
.admin-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
backdrop-filter: blur(2px);
}
.admin-sidebar-overlay.open {
display: block;
}
@media (min-width: 1024px) {
.admin-sidebar-overlay {
display: none !important;
}
}
/* Sidebar Header */
.admin-sidebar-header {
padding: 0 18px;
height: 73px;
border-bottom: 1px solid var(--sb-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.admin-sidebar-logo {
height: 28px;
width: auto;
}
.admin-sidebar-close {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 6px;
}
.admin-sidebar-close:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
@media (min-width: 1024px) {
.admin-sidebar-close {
display: none;
}
}
/* Sidebar Navigation */
.admin-sidebar-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 4px 0;
}
.admin-sidebar-nav::-webkit-scrollbar {
width: 5px;
}
.admin-sidebar-nav::-webkit-scrollbar-track {
background: transparent;
}
.admin-sidebar-nav::-webkit-scrollbar-thumb {
background: var(--sb-scrollbar);
border-radius: 99px;
}
/* Nav Section */
.admin-nav-section {
padding: 14px 10px 6px;
}
.admin-nav-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--sb-label);
padding: 0 8px;
margin-bottom: 4px;
}
/* Nav Item */
.admin-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: var(--sb-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 13px;
font-weight: 450;
margin-bottom: 1px;
text-decoration: none;
user-select: none;
}
.admin-nav-item:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-nav-item.active {
background: var(--sb-active-bg);
color: var(--sb-active-text);
font-weight: 600;
}
.admin-nav-item.active svg {
color: var(--accent-color);
}
.admin-nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 1023px) {
.admin-nav-item {
padding: 10px 12px;
font-size: 15px;
gap: 10px;
}
.admin-nav-item svg {
width: 18px;
height: 18px;
}
}
/* Sidebar Footer */
.admin-sidebar-footer {
margin-top: auto;
border-top: 1px solid var(--sb-border);
padding: 14px 10px;
flex-shrink: 0;
}
.admin-user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
}
.admin-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.admin-user-details {
flex: 1;
min-width: 0;
}
.admin-user-name {
color: var(--sb-text-hover);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-user-role {
color: var(--sb-muted);
font-size: 11px;
}
.admin-logout-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 7px;
font-size: 12px;
font-family: inherit;
transition: all 0.15s ease;
}
.admin-logout-btn:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-logout-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 480px) {
.admin-sidebar-footer {
padding: 10px 8px;
}
.admin-user-chip {
padding: 6px;
}
.admin-logout-btn {
padding: 8px;
font-size: 13px;
}
}
/* ============================================================================
Main Content Area
============================================================================ */
.admin-main {
flex: 1;
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
@media (min-width: 1024px) {
.admin-main {
margin-left: 220px;
}
}
/* Header */
.admin-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
height: calc(73px + env(safe-area-inset-top, 0px));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-top: env(safe-area-inset-top, 0px);
}
@media (min-width: 1024px) {
.admin-header {
left: 220px;
padding: 0 1.5rem;
}
}
.admin-menu-btn {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
}
.admin-menu-btn:hover {
background: var(--bg-tertiary);
}
@media (min-width: 1024px) {
.admin-menu-btn {
display: none;
}
}
.admin-header-theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.admin-header-theme-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-theme-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.3s ease;
opacity: 0;
transform: rotate(180deg) scale(0.5);
}
.admin-theme-icon.visible {
opacity: 1;
transform: rotate(0) scale(1);
}
/* Content */
.admin-content {
flex: 1;
padding: 1rem;
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
}
@media (min-width: 1024px) {
.admin-content {
padding: 28px 32px;
padding-top: calc(73px + 28px);
}
}
/* ============================================================================
Page Headers
============================================================================ */
.admin-page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.admin-page-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.admin-page-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.25rem;
}
.admin-page-subtitle {
color: var(--text-secondary);
font-size: 13px;
}
.admin-page-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.admin-page-actions {
width: 100%;
}
.admin-page-actions .admin-btn {
flex: 1;
}
}
/* ============================================================================
Grid System
============================================================================ */
.admin-grid {
display: grid;
gap: 1rem;
}
.admin-grid > .admin-card {
margin-bottom: 0;
}
.admin-grid-3 {
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.admin-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.admin-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
}
.admin-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.admin-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* Page header on mobile */
@media (max-width: 480px) {
.admin-page-title {
font-size: 18px;
}
.admin-page-subtitle {
font-size: 12px;
}
.admin-content {
padding: 12px !important;
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
}
}
/* Grid - single column on small mobile */
@media (max-width: 480px) {
.admin-grid-4 {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Settings Grid
============================================================================ */
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.admin-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.admin-settings-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -2,63 +2,6 @@
Offers Module Offers Module
============================================ */ ============================================ */
/* Editor section cards */
.offers-editor-section {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
/* Settings grid */
.offers-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.offers-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.offers-settings-grid {
grid-template-columns: 1fr;
}
}
/* Logo section */
.offers-logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.offers-logo-preview {
max-width: 200px;
max-height: 100px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: #fff;
}
.offers-logo-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}
/* Items table */ /* Items table */
.offers-items-table { .offers-items-table {
overflow-x: auto; overflow-x: auto;
@@ -103,213 +46,6 @@
min-height: 32px; min-height: 32px;
} }
/* Totals summary */
.offers-totals-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.offers-totals-row {
display: flex;
gap: 2rem;
justify-content: flex-end;
min-width: 250px;
padding: 0.25rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.offers-totals-row span:last-child {
min-width: 100px;
text-align: right;
font-weight: 500;
color: var(--text-primary);
}
.offers-totals-total {
border-top: 2px solid var(--text-primary);
margin-top: 0.25rem;
padding-top: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.offers-totals-total span:last-child {
font-weight: 700;
}
/* Scope sections list wrapper */
.offers-scope-list {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Scope section card */
.offers-scope-section {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
transition: border-color var(--transition);
background: var(--bg-primary);
}
.offers-scope-content {
overflow: hidden;
}
.offers-scope-section:hover {
border-color: color-mix(
in srgb,
var(--border-color) 70%,
var(--accent-color)
);
}
.offers-scope-section-header {
display: flex;
align-items: center;
padding: 0.625rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
gap: 0.5rem;
}
.offers-scope-section-header .offers-scope-number {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
flex-shrink: 0;
min-width: 1.25rem;
}
.offers-scope-section-header .offers-scope-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.offers-scope-section-header .offers-scope-actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.offers-scope-section .admin-form {
padding: 1rem;
}
/* Customer selector */
.offers-customer-select {
position: relative;
}
.offers-customer-selected {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--input-bg);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.offers-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offers-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.offers-customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.offers-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.offers-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.offers-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.offers-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.offers-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.offers-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.offers-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}
/* Template dropdown menu */ /* Template dropdown menu */
.offers-template-menu { .offers-template-menu {
position: absolute; position: absolute;
@@ -361,81 +97,20 @@
color: var(--danger); color: var(--danger);
} }
/* Compact form row for 3+ columns */
.offers-form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.offers-form-row-3 {
grid-template-columns: 1fr;
}
}
/* Tabs - zachovany pro zpetnou kompatibilitu, nove pouzivat admin-tabs/admin-tab */
.offers-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
margin-bottom: 1.5rem;
max-width: 100%;
overflow-x: auto;
}
.offers-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition:
color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
letter-spacing: 0.01em;
white-space: nowrap;
}
.offers-tab:hover {
color: var(--text-primary);
}
.offers-tab.active {
color: var(--text-primary);
font-weight: 600;
background: var(--bg-secondary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 1px var(--border-color);
}
/* RichEditor (Quill) */ /* RichEditor (Quill) */
.rich-editor { .admin-rich-editor {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: visible; overflow: visible;
} }
.rich-editor .quill { .admin-rich-editor .quill {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Toolbar */ /* Toolbar */
.rich-editor .ql-toolbar.ql-snow { .admin-rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary); background: var(--bg-secondary);
border: none; border: none;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -445,60 +120,60 @@
gap: 2px; gap: 2px;
} }
.rich-editor .ql-toolbar .ql-formats { .admin-rich-editor .ql-toolbar .ql-formats {
margin-right: 8px; margin-right: 8px;
} }
/* Toolbar buttons */ /* Toolbar buttons */
.rich-editor .ql-snow .ql-stroke { .admin-rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary); stroke: var(--text-secondary);
} }
.rich-editor .ql-snow .ql-fill { .admin-rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary); fill: var(--text-secondary);
} }
.rich-editor .ql-snow .ql-picker-label { .admin-rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border-color); border-color: var(--border-color);
} }
.rich-editor .ql-snow button:hover .ql-stroke, .admin-rich-editor .ql-snow button:hover .ql-stroke,
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke { .admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary); stroke: var(--text-primary);
} }
.rich-editor .ql-snow button:hover .ql-fill, .admin-rich-editor .ql-snow button:hover .ql-fill,
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill { .admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary); fill: var(--text-primary);
} }
.rich-editor .ql-snow button:hover, .admin-rich-editor .ql-snow button:hover,
.rich-editor .ql-snow .ql-picker-label:hover { .admin-rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary); color: var(--text-primary);
} }
/* Active state */ /* Active state */
.rich-editor .ql-snow button.ql-active { .admin-rich-editor .ql-snow button.ql-active {
color: var(--accent-color); color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent); background: color-mix(in srgb, var(--accent-color) 15%, transparent);
border-radius: 4px; border-radius: 4px;
} }
.rich-editor .ql-snow button.ql-active .ql-stroke { .admin-rich-editor .ql-snow button.ql-active .ql-stroke {
stroke: var(--accent-color); stroke: var(--accent-color);
} }
.rich-editor .ql-snow button.ql-active .ql-fill, .admin-rich-editor .ql-snow button.ql-active .ql-fill,
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill { .admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
fill: var(--accent-color); fill: var(--accent-color);
} }
.rich-editor .ql-snow .ql-picker-item.ql-selected { .admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
color: var(--accent-color); color: var(--accent-color);
} }
.rich-editor .ql-snow .ql-picker-label.ql-active { .admin-rich-editor .ql-snow .ql-picker-label.ql-active {
color: var(--accent-color); color: var(--accent-color);
} }
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke { .admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
stroke: var(--accent-color); stroke: var(--accent-color);
} }
/* Dropdowns (font, size, color, align) */ /* Dropdowns (font, size, color, align) */
.rich-editor .ql-snow .ql-picker-options { .admin-rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.375rem; border-radius: 0.375rem;
@@ -507,23 +182,23 @@
padding: 0.25rem; padding: 0.25rem;
} }
.rich-editor .ql-snow .ql-picker-item { .admin-rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary); color: var(--text-secondary);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.rich-editor .ql-snow .ql-picker-item:hover { .admin-rich-editor .ql-snow .ql-picker-item:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Font picker */ /* Font picker */
.rich-editor .ql-snow .ql-font .ql-picker-options { .admin-rich-editor .ql-snow .ql-font .ql-picker-options {
min-width: 11rem; min-width: 11rem;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
} }
.rich-editor .ql-snow .ql-size .ql-picker-options { .admin-rich-editor .ql-snow .ql-size .ql-picker-options {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
} }
@@ -703,34 +378,39 @@
} }
/* Editor area */ /* Editor area */
.rich-editor .ql-container.ql-snow { .admin-rich-editor .ql-container.ql-snow {
border: none; border: none;
border-radius: 0 0 0.5rem 0.5rem; border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem; font-family: Tahoma, sans-serif;
font-size: 14px;
} }
.rich-editor .ql-editor { .admin-rich-editor .ql-editor {
min-height: var(--re-min-height, 120px); min-height: var(--re-min-height, 120px);
padding: 0.75rem; padding: 0.75rem;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
font-size: 0.875rem; font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg); background: var(--input-bg);
} }
.rich-editor .ql-editor.ql-blank::before { .admin-rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary); color: var(--text-tertiary);
font-style: normal; font-style: normal;
} }
/* Lists inside editor */ /* Lists inside editor */
.rich-editor .ql-editor ul, .admin-rich-editor .ql-editor ul,
.rich-editor .ql-editor ol { .admin-rich-editor .ql-editor ol {
padding-left: 1.5rem; padding-left: 1.5rem;
} }
/* Color picker */ /* Color picker */
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] { .admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
width: 176px; width: 176px;
padding: 0.375rem; padding: 0.375rem;
display: flex; display: flex;
@@ -738,7 +418,7 @@
gap: 2px; gap: 2px;
} }
.rich-editor .ql-snow .ql-color-picker .ql-picker-item { .admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 2px; border-radius: 2px;
@@ -748,7 +428,7 @@
} }
/* Tooltip (link editor) */ /* Tooltip (link editor) */
.rich-editor .ql-snow .ql-tooltip { .admin-rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.375rem; border-radius: 0.375rem;
@@ -756,7 +436,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.rich-editor .ql-snow .ql-tooltip input[type="text"] { .admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -764,12 +444,12 @@
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
.rich-editor .ql-snow .ql-tooltip a { .admin-rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color); color: var(--accent-color);
} }
/* Read-only rendered rich text (Quill HTML output) */ /* Read-only rendered rich text (Quill HTML output) */
.rich-text-view { .admin-rich-text-view {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
font-size: 0.875rem; font-size: 0.875rem;
@@ -778,56 +458,44 @@
min-width: 0; min-width: 0;
} }
.rich-text-view ul, .admin-rich-text-view ul,
.rich-text-view ol { .admin-rich-text-view ol {
padding-left: 1.5rem; padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem; margin: 0.25rem 0 0.75rem;
} }
.rich-text-view li { .admin-rich-text-view li {
margin-bottom: 0.15rem; margin-bottom: 0.15rem;
} }
.rich-text-view a { .admin-rich-text-view a {
color: var(--accent-color); color: var(--accent-color);
} }
.rich-text-view strong, .admin-rich-text-view strong,
.rich-text-view b { .admin-rich-text-view b {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
display: inline-block; display: inline-block;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.rich-text-view br + b, .admin-rich-text-view br + b,
.rich-text-view br + strong { .admin-rich-text-view br + strong {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.rich-text-view > br:first-child, .admin-rich-text-view > br:first-child,
.rich-text-view ul + br, .admin-rich-text-view ul + br,
.rich-text-view ol + br { .admin-rich-text-view ol + br {
display: none; display: none;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.offers-editor-section {
padding: 1rem;
}
.offers-items-table { .offers-items-table {
margin: 0 -1rem; margin: 0 -1rem;
width: calc(100% + 2rem); width: calc(100% + 2rem);
} }
.offers-totals-summary {
align-items: stretch;
}
.offers-totals-row {
min-width: unset;
}
} }
/* Offer draft row in table */ /* Offer draft row in table */

View File

@@ -108,7 +108,7 @@ export default function Attendance() {
project_logs: [], project_logs: [],
active_project_id: null, active_project_id: null,
}); });
const [showLeaveModal, setShowLeaveModal] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({ const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation", leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0], date_from: new Date().toISOString().split("T")[0],
@@ -122,14 +122,20 @@ export default function Attendance() {
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]); const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null); const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{ const [gpsConfirm, setGpsConfirm] = useState<{
show: boolean; isOpen: boolean;
action: string | null; action: string | null;
}>({ show: false, action: null }); }>({ isOpen: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null); const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
return () => { return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort(); if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
}; };
}, []); }, []);
@@ -173,14 +179,25 @@ export default function Attendance() {
loadProjects(); loadProjects();
}, []); }, []);
useModalLock(showLeaveModal); useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />; if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => { const handlePunch = (action: string) => {
setSubmitting(true); setSubmitting(true);
latestActionRef.current = action;
// Some browsers silently hang on getCurrentPosition (especially with
// enableHighAccuracy:true on desktops without GPS). Use a short safety
// timeout and proceed without GPS rather than leaving the user stuck.
const safetyTimeout = setTimeout(() => {
if (mountedRef.current) {
submitPunch(action, {});
}
}, 6000);
if (!navigator.geolocation) { if (!navigator.geolocation) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná"); alert.warning("GPS není dostupná");
submitPunch(action, {}); submitPunch(action, {});
return; return;
@@ -188,14 +205,21 @@ export default function Attendance() {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
const { latitude, longitude, accuracy } = position.coords; clearTimeout(safetyTimeout);
submitPunch(action, { latitude, longitude, accuracy, address: "" }); 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(); if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController(); const controller = new AbortController();
geoAbortRef.current = controller; geoAbortRef.current = controller;
fetch( fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, `https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
{ {
headers: { "Accept-Language": "cs" }, headers: { "Accept-Language": "cs" },
signal: controller.signal, signal: controller.signal,
@@ -203,13 +227,15 @@ export default function Attendance() {
) )
.then((r) => r.json()) .then((r) => r.json())
.then((geoData) => { .then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) { if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, { apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
latitude, latitude: position.coords.latitude,
longitude, longitude: position.coords.longitude,
address: geoData.display_name, address: geoData.display_name,
punch_action: action, punch_action: action,
}), }),
@@ -219,6 +245,8 @@ export default function Attendance() {
.catch(() => {}); .catch(() => {});
}, },
(geoError) => { (geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu"; let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) { if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut"; errorMsg = "Přístup k poloze byl zamítnut";
@@ -226,9 +254,10 @@ export default function Attendance() {
errorMsg = "Vypršel časový limit"; errorMsg = "Vypršel časový limit";
} }
alert.error(errorMsg); alert.error(errorMsg);
setGpsConfirm({ show: true, action }); setSubmitting(false);
setGpsConfirm({ isOpen: true, action });
}, },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
); );
}; };
@@ -249,7 +278,7 @@ export default function Attendance() {
if (result.success) { if (result.success) {
await fetchData(); await fetchData();
setTimeout(() => { punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo"); alert.success(result.data?.message || result.message || "Uloženo");
}, 300); }, 300);
} else { } else {
@@ -360,7 +389,7 @@ export default function Attendance() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setShowLeaveModal(false); setIsLeaveModalOpen(false);
await fetchData(); await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -576,10 +605,7 @@ export default function Attendance() {
<div className="attendance-project-header"> <div className="attendance-project-header">
<span className="attendance-shift-label">Projekt</span> <span className="attendance-shift-label">Projekt</span>
{activeProjectId ? ( {activeProjectId ? (
<span <span className="admin-badge admin-badge-wrap text-sm">
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.8125rem" }}
>
{projects.find( {projects.find(
(p) => String(p.id) === String(activeProjectId), (p) => String(p.id) === String(activeProjectId),
) )
@@ -587,12 +613,7 @@ export default function Attendance() {
: `Projekt #${activeProjectId}`} : `Projekt #${activeProjectId}`}
</span> </span>
) : ( ) : (
<span <span className="text-muted text-sm">Žádný</span>
className="text-muted"
style={{ fontSize: "0.8125rem" }}
>
Žádný
</span>
)} )}
</div> </div>
<select <select
@@ -601,8 +622,7 @@ export default function Attendance() {
handleSwitchProject(e.target.value || null) handleSwitchProject(e.target.value || null)
} }
disabled={switchingProject} disabled={switchingProject}
className="admin-form-select" className="admin-form-select text-md"
style={{ fontSize: "0.875rem" }}
> >
<option value=""> Bez projektu </option> <option value=""> Bez projektu </option>
{projects.map((p) => ( {projects.map((p) => (
@@ -614,12 +634,16 @@ export default function Attendance() {
{projectLogs.length > 0 && ( {projectLogs.length > 0 && (
<div className="attendance-project-logs"> <div className="attendance-project-logs">
{projectLogs.map((log, i) => { {projectLogs.map((log, i) => {
const start = new Date(log.started_at!); if (!log.started_at) return null;
const start = new Date(log.started_at);
const end = log.ended_at const end = log.ended_at
? new Date(log.ended_at) ? new Date(log.ended_at)
: new Date(); : new Date();
const mins = Math.floor( const mins = Math.max(
(end.getTime() - start.getTime()) / 60000, 0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
); );
const h = Math.floor(mins / 60); const h = Math.floor(mins / 60);
const mm = mins % 60; const mm = mins % 60;
@@ -654,8 +678,7 @@ export default function Attendance() {
<button <button
onClick={handleBreak} onClick={handleBreak}
disabled={submitting} disabled={submitting}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary w-full"
style={{ width: "100%" }}
> >
Pauza (30 min) Pauza (30 min)
</button> </button>
@@ -663,15 +686,13 @@ export default function Attendance() {
<button <button
onClick={() => handlePunch("departure")} onClick={() => handlePunch("departure")}
disabled={submitting} disabled={submitting}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary w-full"
style={{ width: "100%" }}
> >
{submitting ? "Zpracovávám..." : "Odchod"} {submitting ? "Zpracovávám..." : "Odchod"}
</button> </button>
<button <button
onClick={() => setShowLeaveModal(true)} onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary w-full"
style={{ width: "100%" }}
> >
Žádost o nepřítomnost Žádost o nepřítomnost
</button> </button>
@@ -703,16 +724,14 @@ export default function Attendance() {
<button <button
onClick={() => handlePunch("arrival")} onClick={() => handlePunch("arrival")}
disabled={submitting} disabled={submitting}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary w-full"
style={{ width: "100%" }}
> >
{submitting ? "Zpracovávám..." : "Příchod"} {submitting ? "Zpracovávám..." : "Příchod"}
</button> </button>
<button <button
onClick={() => setShowLeaveModal(true)} onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary w-full"
style={{ width: "100%" }}
> >
Žádost o nepřítomnost Žádost o nepřítomnost
</button> </button>
@@ -768,11 +787,12 @@ export default function Attendance() {
}} }}
> >
{shiftLogs.map((log, i) => { {shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at const mins = log.ended_at
? Math.floor( ? Math.floor(
(new Date(log.ended_at).getTime() - (new Date(log.ended_at).getTime() -
new Date( new Date(
log.started_at!, log.started_at,
).getTime()) / ).getTime()) /
60000, 60000,
) )
@@ -877,11 +897,10 @@ export default function Attendance() {
</div> </div>
<div style={{ marginTop: "0.75rem" }}> <div style={{ marginTop: "0.75rem" }}>
<div <div
className="text-secondary" className="text-secondary text-sm"
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
fontSize: "0.8125rem",
marginBottom: "0.5rem", marginBottom: "0.5rem",
}} }}
> >
@@ -905,8 +924,8 @@ export default function Attendance() {
</div> </div>
{data.monthly_fund.leave_hours > 0 && ( {data.monthly_fund.leave_hours > 0 && (
<div <div
className="text-muted" className="text-muted text-xs"
style={{ fontSize: "0.75rem", marginTop: "0.375rem" }} style={{ marginTop: "0.375rem" }}
> >
{"Pokryto: "} {"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "} {data.monthly_fund.covered}h (práce{" "}
@@ -1060,7 +1079,7 @@ export default function Attendance() {
{/* Leave Modal */} {/* Leave Modal */}
<AnimatePresence> <AnimatePresence>
{showLeaveModal && ( {isLeaveModalOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -1070,7 +1089,7 @@ export default function Attendance() {
> >
<div <div
className="admin-modal-backdrop" className="admin-modal-backdrop"
onClick={() => setShowLeaveModal(false)} onClick={() => setIsLeaveModalOpen(false)}
/> />
<motion.div <motion.div
className="admin-modal" className="admin-modal"
@@ -1191,7 +1210,7 @@ export default function Attendance() {
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button <button
type="button" type="button"
onClick={() => setShowLeaveModal(false)} onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
disabled={requestSubmitting} disabled={requestSubmitting}
> >
@@ -1218,13 +1237,13 @@ export default function Attendance() {
</AnimatePresence> </AnimatePresence>
<ConfirmModal <ConfirmModal
isOpen={gpsConfirm.show} isOpen={gpsConfirm.isOpen}
onClose={() => { onClose={() => {
setGpsConfirm({ show: false, action: null }); setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false); setSubmitting(false);
}} }}
onConfirm={() => { onConfirm={() => {
setGpsConfirm({ show: false, action: null }); setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {}); submitPunch(gpsConfirm.action!, {});
}} }}
title="GPS nedostupná" title="GPS nedostupná"

View File

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

View File

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

View File

@@ -39,22 +39,23 @@ export default function AttendanceCreate() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const today = new Date().toISOString().split("T")[0]; const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({ return {
user_id: "", user_id: "",
shift_date: today, shift_date: today,
leave_type: "work", leave_type: "work",
leave_hours: 8, leave_hours: 8,
arrival_date: today, arrival_date: today,
arrival_time: "", arrival_time: "",
break_start_date: today, break_start_date: today,
break_start_time: "", break_start_time: "",
break_end_date: today, break_end_date: today,
break_end_time: "", break_end_time: "",
departure_date: today, departure_date: today,
departure_time: "", departure_time: "",
notes: "", notes: "",
};
}); });
useEffect(() => { useEffect(() => {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
@@ -85,7 +86,6 @@ export default function CompanySettings({
vat_id: "", vat_id: "",
}); });
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const customFieldKeyCounter = useRef(0);
const [fieldOrder, setFieldOrder] = useState<string[]>([ const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER, ...DEFAULT_FIELD_ORDER,
]); ]);
@@ -99,6 +99,10 @@ export default function CompanySettings({
const [bankLoading, setBankLoading] = useState(true); const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false); const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null); const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
isOpen: boolean;
id: number | null;
}>({ isOpen: false, id: null });
const [bankForm, setBankForm] = useState<BankForm>({ const [bankForm, setBankForm] = useState<BankForm>({
account_name: "", account_name: "",
bank_name: "", bank_name: "",
@@ -197,9 +201,17 @@ export default function CompanySettings({
const cf = const cf =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0 Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map( ? d.custom_fields.map(
(f: { name: string; value: string; showLabel?: boolean }) => ({ (
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f, ...f,
_key: `cf-${++customFieldKeyCounter.current}`, _key: f._key || `cf-${Date.now()}-${i}`,
}), }),
) )
: []; : [];
@@ -293,22 +305,31 @@ export default function CompanySettings({
} }
}; };
const handleBankDelete = async (id: number) => { const handleBankDelete = (id: number) => {
if (!confirm("Opravdu smazat tento bankovní účet?")) return; setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try { try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, { const response = await apiFetch(
method: "DELETE", `${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
}); {
method: "DELETE",
},
);
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message); alert.success(result.message);
if (editingBank === id) resetBankForm(); if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts(); fetchBankAccounts();
} else { } else {
alert.error(result.error || "Chyba při mazání"); alert.error(result.error || "Chyba při mazání");
} }
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
} }
}; };
@@ -515,7 +536,7 @@ export default function CompanySettings({
</motion.div> </motion.div>
)} )}
<div className="offers-settings-grid"> <div className="admin-settings-grid">
{/* Company Info */} {/* Company Info */}
<motion.div <motion.div
className="admin-card" className="admin-card"
@@ -716,7 +737,7 @@ export default function CompanySettings({
name: "", name: "",
value: "", value: "",
showLabel: true, showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`, _key: `cf-${Date.now()}`,
}, },
]) ])
} }
@@ -1080,7 +1101,7 @@ export default function CompanySettings({
</div> </div>
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-form-row"> <div className="admin-form-row">
<div className="offers-logo-section"> <div className="admin-logo-section">
<label <label
className="admin-form-label" className="admin-form-label"
style={{ display: "block", marginBottom: 4 }} style={{ display: "block", marginBottom: 4 }}
@@ -1088,7 +1109,7 @@ export default function CompanySettings({
Logo (světlý režim) Logo (světlý režim)
</label> </label>
{logoUrl && ( {logoUrl && (
<div className="offers-logo-preview"> <div className="admin-logo-preview">
<img src={logoUrl} alt="Logo (světlý režim)" /> <img src={logoUrl} alt="Logo (světlý režim)" />
</div> </div>
)} )}
@@ -1130,7 +1151,7 @@ export default function CompanySettings({
PNG, JPEG, GIF nebo WebP, max 5 MB PNG, JPEG, GIF nebo WebP, max 5 MB
</small> </small>
</div> </div>
<div className="offers-logo-section"> <div className="admin-logo-section">
<label <label
className="admin-form-label" className="admin-form-label"
style={{ display: "block", marginBottom: 4 }} style={{ display: "block", marginBottom: 4 }}
@@ -1138,7 +1159,7 @@ export default function CompanySettings({
Logo (tmavý režim) Logo (tmavý režim)
</label> </label>
{logoUrlDark && ( {logoUrlDark && (
<div className="offers-logo-preview"> <div className="admin-logo-preview">
<img src={logoUrlDark} alt="Logo (tmavý režim)" /> <img src={logoUrlDark} alt="Logo (tmavý režim)" />
</div> </div>
)} )}
@@ -1208,6 +1229,17 @@ export default function CompanySettings({
</button> </button>
</motion.div> </motion.div>
)} )}
<ConfirmModal
isOpen={bankDeleteConfirm.isOpen}
onClose={() => setBankDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmBankDelete}
title="Smazat bankovní účet"
message="Opravdu chcete smazat tento bankovní účet?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div> </div>
); );
} }

View File

@@ -129,7 +129,7 @@ export default function Dashboard() {
}, [fetch2FAStatus]); }, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu // Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => { const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival"; const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true); setPunching(true);
@@ -167,7 +167,7 @@ export default function Dashboard() {
() => submitPunch({}), () => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
); );
}; }, [dashData, alert, fetchDashboard]);
// 2FA handlery // 2FA handlery
const handleStart2FASetup = async () => { const handleStart2FASetup = async () => {
@@ -338,7 +338,7 @@ export default function Dashboard() {
{/* Skeleton loading */} {/* Skeleton loading */}
{dashLoading && ( {dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="dash-kpi-grid dash-kpi-4"> <div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<div <div
key={i} key={i}
@@ -493,7 +493,7 @@ export default function Dashboard() {
</span> </span>
</div> </div>
<div className="dash-stat-row"> <div className="dash-stat-row">
<span>Prošlé</span> <span>Zneplatněné</span>
<span className="admin-badge admin-badge-warning"> <span className="admin-badge admin-badge-warning">
{dashData.offers.expired_count} {dashData.offers.expired_count}
</span> </span>

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@ function formatCzkWithDetail(
if (!Array.isArray(amounts) || amounts.length === 0) if (!Array.isArray(amounts) || amounts.length === 0)
return { value: "0 Kč", detail: null }; return { value: "0 Kč", detail: null };
const hasForeign = amounts.some((a) => a.currency !== "CZK"); const hasForeign = amounts.some((a) => a.currency !== "CZK");
if (hasForeign && totalCzk !== null && totalCzk !== undefined) { if (hasForeign && totalCzk != null) {
return { return {
value: formatCurrency(totalCzk, "CZK"), value: formatCurrency(totalCzk, "CZK"),
detail: formatMultiCurrency(amounts), detail: formatMultiCurrency(amounts),
@@ -138,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth = const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear(); statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`; const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -194,7 +204,6 @@ export default function Invoices() {
}>({ show: false, invoice: null }); }>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null); const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [langModal, setLangModal] = useState<Invoice | null>(null);
const [draft, setDraft] = useState<DraftData | null>(() => { const [draft, setDraft] = useState<DraftData | null>(() => {
try { try {
const raw = localStorage.getItem(DRAFT_KEY); const raw = localStorage.getItem(DRAFT_KEY);
@@ -284,29 +293,27 @@ export default function Invoices() {
} }
}; };
const handlePdf = async (inv: Invoice, lang = "cs") => { const handlePdf = async (inv: Invoice) => {
if (pdfLoading) return; if (pdfLoading) return;
setLangModal(null); const newWindow = window.open("", "_blank");
setPdfLoading(inv.id); setPdfLoading(inv.id);
try { try {
const response = await apiFetch( const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`);
`${API_BASE}/invoices-pdf/${inv.id}?lang=${encodeURIComponent(lang)}`, if (response.status === 401) {
); newWindow?.close();
if (response.status === 401) return;
if (!response.ok) {
alert.error("Nepodařilo se vygenerovat PDF");
return; return;
} }
const html = await response.text(); if (!response.ok) {
const w = window.open("", "_blank"); newWindow?.close();
if (w) { alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji");
w.document.open(); return;
w.document.write(html);
w.document.close();
w.onload = () => w.print();
} else {
alert.error("Prohlížeč zablokoval vyskakovací okno");
} }
const blob = await response.blob();
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");
} finally { } finally {
@@ -334,7 +341,7 @@ export default function Invoices() {
style={{ width: "140px", borderRadius: "8px" }} style={{ width: "140px", borderRadius: "8px" }}
/> />
</div> </div>
<div className="dash-kpi-grid dash-kpi-4"> <div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card"> <div key={i} className="admin-stat-card">
<div <div
@@ -497,15 +504,15 @@ export default function Invoices() {
</button> </button>
</div> </div>
<div className="offers-tabs mb-4" style={{ justifyContent: "center" }}> <div className="admin-tabs mb-4" style={{ justifyContent: "center" }}>
<button <button
className={`offers-tab ${activeTab === "issued" ? "active" : ""}`} className={`admin-tab ${activeTab === "issued" ? "active" : ""}`}
onClick={() => setActiveTab("issued")} onClick={() => setActiveTab("issued")}
> >
Vydané Vydané
</button> </button>
<button <button
className={`offers-tab ${activeTab === "received" ? "active" : ""}`} className={`admin-tab ${activeTab === "received" ? "active" : ""}`}
onClick={() => setActiveTab("received")} onClick={() => setActiveTab("received")}
> >
Přijaté Přijaté
@@ -522,7 +529,7 @@ export default function Invoices() {
<Suspense <Suspense
fallback={ fallback={
<div <div
className="dash-kpi-grid dash-kpi-4" className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }} style={{ marginBottom: "1.5rem" }}
> >
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
@@ -569,7 +576,7 @@ export default function Invoices() {
> >
{!hasLoadedOnce.current && statsLoading ? ( {!hasLoadedOnce.current && statsLoading ? (
<div <div
className="dash-kpi-grid dash-kpi-4" className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }} style={{ marginBottom: "1.5rem" }}
> >
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
@@ -607,7 +614,7 @@ export default function Invoices() {
> >
<motion.div <motion.div
key={slideKey} key={slideKey}
className="dash-kpi-grid dash-kpi-4" className="admin-kpi-grid admin-kpi-4"
custom={slideDirection.current} custom={slideDirection.current}
variants={{ variants={{
enter: (dir: number) => ({ enter: (dir: number) => ({
@@ -740,11 +747,11 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="offers-tabs mb-6"> <div className="admin-tabs mb-6">
{STATUS_FILTERS.map((f) => ( {STATUS_FILTERS.map((f) => (
<button <button
key={f.value} key={f.value}
className={`offers-tab ${statusFilter === f.value ? "active" : ""}`} className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
onClick={() => { onClick={() => {
setStatusFilter(f.value); setStatusFilter(f.value);
setPage(1); setPage(1);
@@ -996,26 +1003,44 @@ export default function Invoices() {
<Link <Link
to={`/invoices/${inv.id}`} to={`/invoices/${inv.id}`}
className="admin-btn-icon" className="admin-btn-icon"
title="Detail" title={
aria-label="Detail" inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
> >
<svg {inv.status === "paid" ? (
width="18" <svg
height="18" width="18"
viewBox="0 0 24 24" height="18"
fill="none" viewBox="0 0 24 24"
stroke="currentColor" fill="none"
strokeWidth="2" stroke="currentColor"
> strokeWidth="2"
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> >
<circle cx="12" cy="12" r="3" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
</svg> <circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link> </Link>
{hasPermission("invoices.export") && ( {hasPermission("invoices.export") && (
<button <button
onClick={() => setLangModal(inv)} onClick={() => handlePdf(inv)}
className="admin-btn-icon" className="admin-btn-icon"
title="PDF" title="Zobrazit fakturu"
disabled={pdfLoading === inv.id} disabled={pdfLoading === inv.id}
> >
{pdfLoading === inv.id ? ( {pdfLoading === inv.id ? (
@@ -1092,69 +1117,6 @@ export default function Invoices() {
type="danger" type="danger"
loading={deleting} loading={deleting}
/> />
<AnimatePresence>
{langModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setLangModal(null)}
/>
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">
V jakém jazyce chcete vygenerovat fakturu?
</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => handlePdf(langModal, "cs")}
className="admin-btn admin-btn-primary"
>
Čeština
</button>
<button
type="button"
onClick={() => handlePdf(langModal, "en")}
className="admin-btn admin-btn-primary"
>
English
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
)} )}
</div> </div>

View File

@@ -163,8 +163,8 @@ export default function LeaveApproval() {
: []), : []),
].sort( ].sort(
(a: LeaveRequest, b: LeaveRequest) => (a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() - (b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
new Date(a.reviewed_at!).getTime(), (a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
); );
setProcessedRequests(all); setProcessedRequests(all);
@@ -313,9 +313,9 @@ export default function LeaveApproval() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<div className="offers-tabs mb-6"> <div className="admin-tabs mb-6">
<button <button
className={`offers-tab ${activeTab === "pending" ? "active" : ""}`} className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")} onClick={() => setActiveTab("pending")}
> >
Ke schválení Ke schválení
@@ -333,7 +333,7 @@ export default function LeaveApproval() {
)} )}
</button> </button>
<button <button
className={`offers-tab ${activeTab === "processed" ? "active" : ""}`} className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")} onClick={() => setActiveTab("processed")}
> >
Vyřízené Vyřízené

View File

@@ -61,7 +61,7 @@ export default function LeaveRequests() {
const fetchRequests = useCallback(async () => { const fetchRequests = useCallback(async () => {
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests`); const response = await apiFetch(`${API_BASE}/leave-requests?mine=1`);
if (response.status === 401) return; if (response.status === 401) return;
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) { } else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni."); alert.success("Byli jste úspěšně odhlášeni.");
} }
}, [alert]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input // Auto-focus TOTP input
useEffect(() => { useEffect(() => {
@@ -43,6 +44,92 @@ export default function Login() {
} }
}, [show2FA, useBackupCode]); }, [show2FA, useBackupCode]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const result = await login(username, password, remember);
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
},
[
username,
password,
remember,
login,
alert,
setLoading,
setShake,
setAnimatingOut,
setLoginToken,
setShow2FA,
setTotpCode,
],
);
const handle2FASubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!totpCode.trim()) return;
setLoading(true);
const result = await verify2FA(
loginToken!,
totpCode.trim(),
remember,
useBackupCode,
);
if (!result.success) {
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
},
[
totpCode,
loginToken,
remember,
useBackupCode,
verify2FA,
alert,
setLoading,
setShake,
setTotpCode,
setAnimatingOut,
],
);
const handleBack = useCallback(() => {
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
setUseBackupCode(false);
}, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]);
if (authLoading) { if (authLoading) {
return ( return (
<div className="admin-login"> <div className="admin-login">
@@ -57,63 +144,6 @@ export default function Login() {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const result = await login(username, password, remember);
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
};
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!totpCode.trim()) return;
setLoading(true);
const result = await verify2FA(
loginToken!,
totpCode.trim(),
remember,
useBackupCode,
);
if (!result.success) {
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
};
const handleBack = () => {
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
setUseBackupCode(false);
};
return ( return (
<motion.div <motion.div
className="admin-login" className="admin-login"

View File

@@ -2,6 +2,7 @@ import {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useMemo,
useRef, useRef,
type ChangeEvent, type ChangeEvent,
} from "react"; } from "react";
@@ -55,9 +56,6 @@ interface OfferItem {
is_included_in_total: boolean; is_included_in_total: boolean;
} }
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection { interface ScopeSection {
title: string; title: string;
title_cz: string; title_cz: string;
@@ -113,16 +111,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "", content: "",
}); });
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({ function SortableItemRow({
item, item,
index, index,
@@ -180,9 +168,7 @@ function SortableItemRow({
</button> </button>
</td> </td>
)} )}
<td style={{ textAlign: "center", color: "var(--text-tertiary)" }}> <td className="text-center text-tertiary">{index + 1}</td>
{index + 1}
</td>
<td style={{ verticalAlign: "top" }}> <td style={{ verticalAlign: "top" }}>
<div <div
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }} style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
@@ -191,10 +177,9 @@ function SortableItemRow({
type="text" type="text"
value={item.description} value={item.description}
onChange={(e) => onUpdate("description", e.target.value)} onChange={(e) => onUpdate("description", e.target.value)}
className="admin-form-input" className="admin-form-input fw-500"
placeholder="Název položky" placeholder="Název položky"
readOnly={readOnly} readOnly={readOnly}
style={{ fontWeight: 500 }}
/> />
<input <input
type="text" type="text"
@@ -240,7 +225,7 @@ function SortableItemRow({
readOnly={readOnly} readOnly={readOnly}
/> />
</td> </td>
<td style={{ textAlign: "center" }}> <td className="text-center">
<input <input
type="checkbox" type="checkbox"
checked={item.is_included_in_total} checked={item.is_included_in_total}
@@ -248,10 +233,7 @@ function SortableItemRow({
disabled={readOnly} disabled={readOnly}
/> />
</td> </td>
<td <td className="admin-mono text-right fw-600">
className="admin-mono"
style={{ textAlign: "right", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)} {formatCurrency(lineTotal, currency)}
</td> </td>
{!readOnly && ( {!readOnly && (
@@ -280,6 +262,19 @@ function SortableItemRow({
); );
} }
function loadOfferDraft(): {
form?: Record<string, unknown>;
items?: unknown[];
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem("boha_offer_draft");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function OfferDetail() { export default function OfferDetail() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
@@ -294,12 +289,56 @@ export default function OfferDetail() {
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
const itemKeyCounter = useRef(0);
const emptyItem = useCallback(
(): OfferItem => ({
_key: `item-${++itemKeyCounter.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
}),
[],
);
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm); const [form, setForm] = useState<OfferForm>(() => {
const [items, setItems] = useState<OfferItem[]>([emptyItem()]); const draft = loadOfferDraft();
const [sections, setSections] = useState<ScopeSection[]>([]); if (draft?.form) {
return {
...emptyForm,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
return draft.items as OfferItem[];
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [scopeTemplates, setScopeTemplates] = useState< const [scopeTemplates, setScopeTemplates] = useState<
Array<{ Array<{
id: number; id: number;
@@ -327,6 +366,7 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null); const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{ const [companySettings, setCompanySettings] = useState<{
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
@@ -339,34 +379,41 @@ export default function OfferDetail() {
full_name: string; full_name: string;
} | null>(null); } | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
useModalLock(showOrderModal); useModalLock(showOrderModal);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => { useEffect(() => {
apiFetch(`${API_BASE}/company-settings`) apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json()) .then((r) => r.json())
.then((d) => { .then((d) => {
if (d.success) setCompanySettings(d.data); if (d.success) {
setCompanySettings(d.data);
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(() => {}); .catch(() => {});
}, []); }, []);
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 isInvalidated = offerStatus === "invalidated"; const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy; const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated = const isExpiredNotInvalidated =
@@ -384,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const d = result.data; const d = result.data;
setForm({ const formData = {
quotation_number: d.quotation_number || "", quotation_number: d.quotation_number || "",
project_code: d.project_code || "", project_code: d.project_code || "",
customer_id: d.customer_id || null, customer_id: d.customer_id || null,
@@ -400,21 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "", exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "", scope_title: d.scope_title || "",
scope_description: d.scope_description || "", scope_description: d.scope_description || "",
};
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,
}); });
setItems(
d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() }))
: [emptyItem()],
);
setSections(
d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [],
);
setOfferStatus(d.status || ""); setOfferStatus(d.status || "");
setOrderInfo(d.order || null); setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null); setLockedBy(d.locked_by || null);
@@ -453,10 +507,14 @@ export default function OfferDetail() {
return () => { return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
// Release lock on unmount // Release lock on unmount
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch( const controller = new AbortController();
() => {}, unlockAbortRef.current = controller;
); apiFetch(`${API_BASE}/offers/${id}/unlock`, {
method: "POST",
signal: controller.signal,
}).catch(() => {});
}; };
}, [isEdit, id, isLockedByOther, isInvalidated]); }, [isEdit, id, isLockedByOther, isInvalidated]);
@@ -464,6 +522,28 @@ export default function OfferDetail() {
if (isEdit) fetchDetail(); if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]); }, [isEdit, fetchDetail]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
useEffect(() => { useEffect(() => {
const loadCustomers = async () => { const loadCustomers = async () => {
try { try {
@@ -523,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber(); fetchNextNumber();
}, [isEdit]); }, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only) // Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections }); const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500); const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -641,14 +688,17 @@ export default function OfferDetail() {
setSaving(true); setSaving(true);
try { try {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const payload: any = {
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
};
if (!isEdit) delete payload.quotation_number;
const response = await apiFetch(url, { const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST", method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(payload),
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
}),
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
@@ -672,6 +722,13 @@ export default function OfferDetail() {
if (!isEdit && result.data?.id) { if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`); navigate(`/offers/${result.data.id}`);
} }
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
}
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nabídku"); alert.error(result.error || "Nepodařilo se uložit nabídku");
} }
@@ -784,7 +841,8 @@ export default function OfferDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");
@@ -874,11 +932,10 @@ export default function OfferDetail() {
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} {isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
{isInvalidated && ( {isInvalidated && (
<span <span
className="admin-badge admin-badge-danger" className="admin-badge admin-badge-danger text-xs"
style={{ style={{
marginLeft: "0.75rem", marginLeft: "0.75rem",
verticalAlign: "middle", verticalAlign: "middle",
fontSize: "0.75rem",
}} }}
> >
Zneplatněna Zneplatněna
@@ -1011,25 +1068,24 @@ export default function OfferDetail() {
{/* Quotation Form */} {/* Quotation Form */}
<motion.div <motion.div
className={`offers-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`} className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<h3 className="admin-card-title">Základní údaje</h3> <h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form"> <div className="admin-form">
<div className="offers-form-row-3"> <div className="admin-form-row admin-form-row-3">
<FormField label="Číslo nabídky"> <FormField label="Číslo nabídky">
<input <input
type="text" type="text"
value={form.quotation_number} value={form.quotation_number}
onChange={(e) => readOnly
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
className="admin-form-input" className="admin-form-input"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/> />
</FormField> </FormField>
<FormField label="Kód projektu"> <FormField label="Kód projektu">
@@ -1044,7 +1100,7 @@ export default function OfferDetail() {
</FormField> </FormField>
<FormField label="Zákazník" error={errors.customer_id}> <FormField label="Zákazník" error={errors.customer_id}>
{form.customer_id ? ( {form.customer_id ? (
<div className="offers-customer-selected"> <div className="admin-customer-selected">
<span>{form.customer_name}</span> <span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && (
<button <button
@@ -1069,7 +1125,7 @@ export default function OfferDetail() {
</div> </div>
) : ( ) : (
<div <div
className="offers-customer-select" className="admin-customer-select"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<input <input
@@ -1085,16 +1141,16 @@ export default function OfferDetail() {
readOnly={isInvalidated || isLockedByOther} readOnly={isInvalidated || isLockedByOther}
/> />
{showCustomerDropdown && !isInvalidated && ( {showCustomerDropdown && !isInvalidated && (
<div className="offers-customer-dropdown"> <div className="admin-customer-dropdown">
{filteredCustomers.length === 0 ? ( {filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty"> <div className="admin-customer-dropdown-empty">
Žádní zákazníci Žádní zákazníci
</div> </div>
) : ( ) : (
filteredCustomers.slice(0, 20).map((c) => ( filteredCustomers.slice(0, 20).map((c) => (
<div <div
key={c.id} key={c.id}
className="offers-customer-dropdown-item" className="admin-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)} onMouseDown={() => selectCustomer(c)}
> >
<div>{c.name}</div> <div>{c.name}</div>
@@ -1189,7 +1245,7 @@ export default function OfferDetail() {
</FormField> </FormField>
</div> </div>
<div className="offers-form-row-3"> <div className="admin-form-row admin-form-row-3">
<FormField label="Sazba DPH (%)"> <FormField label="Sazba DPH (%)">
<div className="flex-row-gap"> <div className="flex-row-gap">
<select <select
@@ -1208,10 +1264,7 @@ export default function OfferDetail() {
</option> </option>
))} ))}
</select> </select>
<label <label className="admin-form-checkbox whitespace-nowrap">
className="admin-form-checkbox"
style={{ whiteSpace: "nowrap" }}
>
<input <input
type="checkbox" type="checkbox"
checked={form.apply_vat} checked={form.apply_vat}
@@ -1332,18 +1385,18 @@ export default function OfferDetail() {
</div> </div>
{/* Totals */} {/* Totals */}
<div className="offers-totals-summary"> <div className="admin-totals-summary">
<div className="offers-totals-row"> <div className="admin-totals-row">
<span>Mezisoučet:</span> <span>Mezisoučet:</span>
<span>{formatCurrency(subtotal, form.currency)}</span> <span>{formatCurrency(subtotal, form.currency)}</span>
</div> </div>
{form.apply_vat && ( {form.apply_vat && (
<div className="offers-totals-row"> <div className="admin-totals-row">
<span>DPH ({form.vat_rate}%):</span> <span>DPH ({form.vat_rate}%):</span>
<span>{formatCurrency(vatAmount, form.currency)}</span> <span>{formatCurrency(vatAmount, form.currency)}</span>
</div> </div>
)} )}
<div className="offers-totals-row offers-totals-total"> <div className="admin-totals-row admin-totals-total">
<span>Celkem:</span> <span>Celkem:</span>
<span>{formatCurrency(total, form.currency)}</span> <span>{formatCurrency(total, form.currency)}</span>
</div> </div>
@@ -1672,7 +1725,7 @@ export default function OfferDetail() {
<FormField label="Příloha (PDF)"> <FormField label="Příloha (PDF)">
{orderAttachment ? ( {orderAttachment ? (
<div className="flex-row gap-2"> <div className="flex-row gap-2">
<span style={{ fontSize: "0.875rem" }}> <span className="text-md">
{orderAttachment.name}{" "} {orderAttachment.name}{" "}
<span className="text-tertiary"> <span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB) ({(orderAttachment.size / 1024).toFixed(0)} KB)

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -63,6 +63,16 @@ export default function Offers() {
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false); const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null); const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null); const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null); const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
@@ -237,9 +247,11 @@ export default function Offers() {
return; return;
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); if (blobUrlRef.current) {
if (newWindow) newWindow.location.href = url; URL.revokeObjectURL(blobUrlRef.current);
setTimeout(() => URL.revokeObjectURL(url), 60000); }
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");
@@ -814,8 +826,8 @@ export default function Offers() {
<tr> <tr>
<td <td
colSpan={8} colSpan={8}
className="text-muted" className="text-muted text-center"
style={{ textAlign: "center", padding: "1.5rem" }} style={{ padding: "1.5rem" }}
> >
Žádné nabídky odpovídající hledání. Žádné nabídky odpovídající hledání.
</td> </td>
@@ -879,8 +891,8 @@ export default function Offers() {
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title">Vytvořit objednávku</h2> <h2 className="admin-modal-title">Vytvořit objednávku</h2>
<p <p
className="text-secondary" className="text-secondary text-md"
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }} style={{ marginTop: "0.25rem" }}
> >
Nabídka:{" "} Nabídka:{" "}
<strong>{orderModal.quotation?.quotation_number}</strong> <strong>{orderModal.quotation?.quotation_number}</strong>
@@ -917,7 +929,7 @@ export default function Offers() {
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
</svg> </svg>
<span style={{ fontSize: "0.875rem" }}> <span className="text-md">
{orderAttachment.name}{" "} {orderAttachment.name}{" "}
<span className="text-tertiary"> <span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB) ({(orderAttachment.size / 1024).toFixed(0)} KB)
@@ -944,11 +956,9 @@ export default function Offers() {
</div> </div>
) : ( ) : (
<label <label
className="admin-btn admin-btn-secondary admin-btn-sm" className="admin-btn admin-btn-secondary admin-btn-sm inline-flex"
style={{ style={{
cursor: "pointer", cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem", gap: "0.4rem",
}} }}
> >

View File

@@ -73,15 +73,15 @@ export default function OffersTemplates() {
</div> </div>
</motion.div> </motion.div>
<div className="offers-tabs"> <div className="admin-tabs">
<button <button
className={`offers-tab ${activeTab === "items" ? "active" : ""}`} className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
onClick={() => setActiveTab("items")} onClick={() => setActiveTab("items")}
> >
Šablony položek Šablony položek
</button> </button>
<button <button
className={`offers-tab ${activeTab === "scopes" ? "active" : ""}`} className={`admin-tab ${activeTab === "scopes" ? "active" : ""}`}
onClick={() => setActiveTab("scopes")} onClick={() => setActiveTab("scopes")}
> >
Šablony rozsahu Šablony rozsahu
@@ -826,22 +826,19 @@ function ScopeTemplatesTab() {
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label mb-2">Sekce</label> <label className="admin-form-label mb-2">Sekce</label>
<div className="offers-scope-list"> <div className="admin-scope-list">
{form.sections.map((section, index) => ( {form.sections.map((section, index) => (
<div <div key={section._key} className="admin-scope-section">
key={section._key} <div className="admin-scope-section-header">
className="offers-scope-section" <span className="admin-scope-number">
>
<div className="offers-scope-section-header">
<span className="offers-scope-number">
{index + 1}. {index + 1}.
</span> </span>
<span className="offers-scope-title"> <span className="admin-scope-title">
{section.title || {section.title ||
section.title_cz || section.title_cz ||
`Sekce ${index + 1}`} `Sekce ${index + 1}`}
</span> </span>
<div className="offers-scope-actions"> <div className="admin-scope-actions">
<button <button
type="button" type="button"
onClick={() => moveSection(index, -1)} onClick={() => moveSection(index, -1)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,7 +115,7 @@ function formatCzkWithDetail(
return { value: "0 Kč", detail: null }; return { value: "0 Kč", detail: null };
} }
const hasForeign = amounts.some((a) => a.currency !== "CZK"); const hasForeign = amounts.some((a) => a.currency !== "CZK");
if (hasForeign && totalCzk !== null && totalCzk !== undefined) { if (hasForeign && totalCzk != null) {
return { return {
value: formatCurrency(totalCzk, "CZK"), value: formatCurrency(totalCzk, "CZK"),
detail: formatMultiCurrency(amounts), detail: formatMultiCurrency(amounts),
@@ -161,6 +161,7 @@ export default function ReceivedInvoices({
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
const prevMonth = useRef(statsMonth); const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear); const prevYear = useRef(statsYear);
@@ -186,17 +187,19 @@ export default function ReceivedInvoices({
useModalLock(uploadOpen || editOpen); useModalLock(uploadOpen || editOpen);
useEffect(() => { useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current; return () => {
const curr = statsYear * 12 + statsMonth; blobTimeoutsRef.current.forEach(clearTimeout);
if (curr > prev) { };
slideDirection.current = 1; }, []);
}
if (curr < prev) { // Compute slide direction during render (not in effect) so it's
slideDirection.current = -1; // available for the current frame instead of one render late.
} const prevTotal = prevYear.current * 12 + prevMonth.current;
prevMonth.current = statsMonth; const currTotal = statsYear * 12 + statsMonth;
prevYear.current = statsYear; if (currTotal > prevTotal) slideDirection.current = 1;
}, [statsMonth, statsYear]); else if (currTotal < prevTotal) slideDirection.current = -1;
prevMonth.current = statsMonth;
prevYear.current = statsYear;
const fetchList = useCallback(async () => { const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true); if (!hasLoadedOnce.current) setLoading(true);
@@ -516,7 +519,8 @@ export default function ReceivedInvoices({
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");
@@ -549,7 +553,7 @@ export default function ReceivedInvoices({
const renderKpi = () => { const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) { if (!hasLoadedOnce.current && statsLoading) {
return ( return (
<div className="dash-kpi-grid dash-kpi-4 mb-6"> <div className="admin-kpi-grid admin-kpi-4 mb-6">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card"> <div key={i} className="admin-stat-card">
<div <div
@@ -586,7 +590,7 @@ export default function ReceivedInvoices({
> >
<motion.div <motion.div
key={slideKey} key={slideKey}
className="dash-kpi-grid dash-kpi-4" className="admin-kpi-grid admin-kpi-4"
custom={slideDirection.current} custom={slideDirection.current}
variants={{ variants={{
enter: (dir: number) => ({ enter: (dir: number) => ({
@@ -709,9 +713,9 @@ export default function ReceivedInvoices({
<p>Žádné přijaté faktury v tomto měsíci.</p> <p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission("invoices.create") && ( {hasPermission("invoices.create") && (
<p <p
className="text-md"
style={{ style={{
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
fontSize: "0.875rem",
}} }}
> >
Nahrajte faktury tlačítkem výše. Nahrajte faktury tlačítkem výše.
@@ -780,7 +784,8 @@ export default function ReceivedInvoices({
/> />
</th> </th>
<th <th
style={{ textAlign: "right", cursor: "pointer" }} className="text-right"
style={{ cursor: "pointer" }}
onClick={() => handleSort("amount")} onClick={() => handleSort("amount")}
> >
Částka{" "} Částka{" "}
@@ -959,9 +964,9 @@ export default function ReceivedInvoices({
Vybrat soubory Vybrat soubory
</button> </button>
<span <span
className="text-sm"
style={{ style={{
marginLeft: "0.75rem", marginLeft: "0.75rem",
fontSize: "0.8125rem",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
}} }}
> >
@@ -1106,8 +1111,8 @@ export default function ReceivedInvoices({
</div> </div>
{uploadMeta[idx]?.amount && ( {uploadMeta[idx]?.amount && (
<div <div
className="text-xs"
style={{ style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
marginTop: "-0.25rem", marginTop: "-0.25rem",
marginBottom: "0.5rem", marginBottom: "0.5rem",
@@ -1320,8 +1325,8 @@ export default function ReceivedInvoices({
</div> </div>
{editInvoice.amount && ( {editInvoice.amount && (
<div <div
className="text-xs"
style={{ style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
marginBottom: "0.75rem", marginBottom: "0.75rem",
}} }}

View File

@@ -76,6 +76,7 @@ export default function Settings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]); const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState< const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]> Record<string, Permission[]>
@@ -161,12 +162,14 @@ export default function Settings() {
return; return;
} }
try { try {
const [rolesRes, permsRes] = await Promise.all([ const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`), apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]); ]);
const rolesResult = await rolesRes.json(); const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json(); const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (rolesResult.success) { if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []); setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
@@ -188,6 +191,10 @@ export default function Settings() {
} }
setPermissionGroups(groups); setPermissionGroups(groups);
} }
if (usersResult.success) {
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
}
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally { } finally {
@@ -639,17 +646,16 @@ export default function Settings() {
</div> </div>
<div> <div>
<div <div
className="fw-500 text-md"
style={{ style={{
fontWeight: 500,
color: "var(--text-primary)", color: "var(--text-primary)",
fontSize: "0.875rem",
}} }}
> >
Povinné dvoufaktorové ověření (2FA) Povinné dvoufaktorové ověření (2FA)
</div> </div>
<div <div
className="text-xs"
style={{ style={{
fontSize: "0.75rem",
color: "var(--text-secondary)", color: "var(--text-secondary)",
}} }}
> >
@@ -781,16 +787,16 @@ export default function Settings() {
<tr key={role.id}> <tr key={role.id}>
<td> <td>
<div <div
className="fw-500"
style={{ style={{
fontWeight: 500,
color: "var(--text-primary)", color: "var(--text-primary)",
}} }}
> >
{role.display_name} {role.display_name}
</div> </div>
<div <div
className="text-xs"
style={{ style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
}} }}
> >
@@ -809,7 +815,7 @@ export default function Settings() {
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-secondary"> <span className="admin-badge admin-badge-secondary">
{0} {users.filter((u) => u.role_id === role.id).length}
</span> </span>
</td> </td>
<td> <td>
@@ -839,16 +845,21 @@ export default function Settings() {
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title={ title={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
aria-label={ aria-label={
0 > 0 users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
disabled={0 > 0} disabled={
users.filter((u) => u.role_id === role.id)
.length > 0
}
> >
<svg <svg
width="16" width="16"
@@ -1077,9 +1088,9 @@ export default function Settings() {
/> />
</FormField> </FormField>
<small <small
className="text-xs"
style={{ style={{
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
fontSize: "0.75rem",
}} }}
> >
Změna se projeví po restartu serveru Změna se projeví po restartu serveru
@@ -1179,10 +1190,9 @@ export default function Settings() {
/> />
)} )}
<div <div
className="fw-600 text-md"
style={{ style={{
fontWeight: 600,
marginBottom: "0.5rem", marginBottom: "0.5rem",
fontSize: "0.875rem",
}} }}
> >
{cfg.label} {cfg.label}
@@ -1355,8 +1365,8 @@ export default function Settings() {
<div className="admin-card-body"> <div className="admin-card-body">
{systemInfo ? ( {systemInfo ? (
<table <table
className="w-full"
style={{ style={{
width: "100%",
fontSize: "0.85rem", fontSize: "0.85rem",
borderCollapse: "collapse", borderCollapse: "collapse",
}} }}
@@ -1374,16 +1384,16 @@ export default function Settings() {
).map(([label, val]) => ( ).map(([label, val]) => (
<tr key={label}> <tr key={label}>
<td <td
className="whitespace-nowrap"
style={{ style={{
padding: "6px 12px 6px 0", padding: "6px 12px 6px 0",
color: "var(--text-secondary)", color: "var(--text-secondary)",
whiteSpace: "nowrap",
width: 160, width: 160,
}} }}
> >
{label} {label}
</td> </td>
<td style={{ padding: "6px 0", fontWeight: 500 }}> <td className="fw-500" style={{ padding: "6px 0" }}>
{val} {val}
</td> </td>
</tr> </tr>
@@ -1516,9 +1526,9 @@ export default function Settings() {
</span> </span>
{info?.configured && ( {info?.configured && (
<span <span
className="text-xs"
style={{ style={{
marginLeft: 8, marginLeft: 8,
fontSize: "0.75rem",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
}} }}
> >
@@ -1548,8 +1558,7 @@ export default function Settings() {
<button <button
onClick={handleSaveSystemSettings} onClick={handleSaveSystemSettings}
disabled={sysSettingsSaving} disabled={sysSettingsSaving}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary w-full"
style={{ width: "100%" }}
> >
{sysSettingsSaving ? ( {sysSettingsSaving ? (
<> <>
@@ -1637,9 +1646,9 @@ export default function Settings() {
/> />
{!editingRole && ( {!editingRole && (
<small <small
className="text-xs"
style={{ style={{
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
fontSize: "0.75rem",
}} }}
> >
Pouze malá písmena, čísla a pomlčky. Nelze později Pouze malá písmena, čísla a pomlčky. Nelze později

View File

@@ -127,7 +127,7 @@ export default function TripsAdmin() {
try { try {
const [vRes, uRes, csRes] = await Promise.all([ const [vRes, uRes, csRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`), apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`), apiFetch(`${API_BASE}/trips/users`),
apiFetch(`${API_BASE}/company-settings`), apiFetch(`${API_BASE}/company-settings`),
]); ]);
const vJson = await vRes.json(); const vJson = await vRes.json();
@@ -136,14 +136,7 @@ export default function TripsAdmin() {
if (vJson.success) setVehicles(vJson.data); if (vJson.success) setVehicles(vJson.data);
if (csJson.success) setCompanyName(csJson.data.company_name || ""); if (csJson.success) setCompanyName(csJson.data.company_name || "");
if (uJson.success) { if (uJson.success) {
setUsers( setUsers(uJson.data);
uJson.data.map(
(u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
}),
),
);
} }
} catch { } catch {
// silently fail, filters will just be empty // silently fail, filters will just be empty

View File

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

91
src/admin/pagination.css Normal file
View File

@@ -0,0 +1,91 @@
/* ============================================================================
Pagination
============================================================================ */
.admin-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border-color);
font-size: 13px;
}
.admin-pagination-info {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
white-space: nowrap;
}
.admin-pagination-controls {
display: flex;
align-items: center;
gap: 2px;
}
.admin-pagination-page {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: var(--border-radius-sm);
background: none;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.admin-pagination-page:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.admin-pagination-page.active {
background: var(--accent-color);
color: #fff;
border-color: var(--accent-color);
font-weight: 600;
}
.admin-pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
color: var(--text-muted);
font-size: 14px;
}
.admin-pagination-select {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
@media (max-width: 640px) {
.admin-pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.admin-pagination-info {
order: 2;
width: 100%;
text-align: center;
}
}

6
src/admin/responsive.css Normal file
View File

@@ -0,0 +1,6 @@
/* ============================================================================
Responsive — Cross-component media queries
============================================================================
Component-specific media queries live in their respective files.
This file is reserved for responsive rules that span multiple components.
============================================================================ */

72
src/admin/skeleton.css vendored Normal file
View File

@@ -0,0 +1,72 @@
/* ============================================================================
Skeleton Loading
============================================================================ */
.admin-skeleton {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
opacity: 0;
animation: skeleton-fade-in 0.15s ease 0.08s forwards;
}
@keyframes skeleton-fade-in {
to {
opacity: 1;
}
}
.admin-skeleton-row {
display: flex;
gap: 1rem;
align-items: center;
}
.admin-skeleton-line {
height: 14px;
border-radius: 6px;
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--border-color) 50%,
var(--bg-tertiary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
}
.admin-skeleton-line.w-full {
width: 100%;
}
.admin-skeleton-line.w-3\/4 {
width: 75%;
}
.admin-skeleton-line.w-1\/2 {
width: 50%;
}
.admin-skeleton-line.w-1\/3 {
width: 33%;
}
.admin-skeleton-line.w-1\/4 {
width: 25%;
}
.admin-skeleton-line.h-8 {
height: 32px;
}
.admin-skeleton-line.h-10 {
height: 40px;
}
.admin-skeleton-line.circle {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
/* Skeleton loading on mobile */
@media (max-width: 640px) {
.admin-skeleton {
border-radius: 4px;
}
}

132
src/admin/tables.css Normal file
View File

@@ -0,0 +1,132 @@
/* ============================================================================
Tables
============================================================================ */
.admin-table-wrapper,
.admin-table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-table {
width: 100%;
min-width: 650px;
border-collapse: collapse;
}
.admin-table th {
text-align: left;
padding: 10px 16px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
.admin-table td {
padding: 11px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
white-space: nowrap;
}
.admin-table tr:last-child td {
border-bottom: none;
}
@media (max-width: 768px) {
.admin-table th,
.admin-table td {
padding: 8px 10px;
font-size: 12px;
}
.admin-table th {
font-size: 10px;
}
.admin-table-avatar {
width: 32px;
height: 32px;
font-size: 11px;
}
.admin-table-name {
font-size: 12px;
}
.admin-table-username {
font-size: 11px;
}
}
.admin-table-user {
display: flex;
align-items: center;
gap: 0.75rem;
white-space: nowrap;
}
.admin-table-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 12px;
}
.admin-table-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.admin-table-username {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.admin-table-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Tables - compact on mobile, better scroll indication */
@media (max-width: 640px) {
.admin-table-wrapper,
.admin-table-responsive {
margin: 0 -1rem;
padding: 0 1rem;
position: relative;
}
.admin-table {
min-width: 500px;
}
.admin-table th,
.admin-table td {
padding: 8px;
font-size: 11px;
}
.admin-table th {
font-size: 9px;
}
.admin-table-actions {
gap: 0.25rem;
}
}

View File

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

159
src/admin/variables.css Normal file
View File

@@ -0,0 +1,159 @@
/* ============================================================================
CSS Variables
============================================================================ */
:root {
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Shared colors */
--accent-color: #d63031;
--accent-hover: #b52626;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--error: var(--danger);
--muted: #9ca3af;
--gradient: #d63031;
--gradient-subtle: rgba(214, 48, 49, 0.9);
/* Shared layout */
--border-radius: 10px;
--border-radius-sm: 8px;
--border-radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
--font-heading: "Urbanist", sans-serif;
--font-body: "Plus Jakarta Sans", sans-serif;
--font-mono: "DM Mono", "Menlo", monospace;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--navbar-height: calc(76px + var(--safe-top));
}
/* ---- Dark theme ---- */
[data-theme="dark"] {
--bg-primary: #0f0f0f;
--bg-secondary: #171717;
--bg-tertiary: #1e1e1e;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--text-tertiary: #555555;
--border-color: rgba(255, 255, 255, 0.08);
--border-color-hover: rgba(255, 255, 255, 0.15);
--glass-bg: #171717;
--glass-bg-solid: #171717;
--glass-border: rgba(255, 255, 255, 0.08);
--glass-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15);
--card-bg: #1a1a1a;
--card-bg-hover: #1e1e1e;
--input-bg: #1a1a1a;
--glow-color: rgba(214, 48, 49, 0.15);
--accent-light: rgba(214, 48, 49, 0.1);
--accent-soft: #2a1a1a;
--accent-glow: rgba(214, 48, 49, 0.3);
--success-light: rgba(34, 197, 94, 0.1);
--success-soft: #1a2a1e;
--warning-light: rgba(245, 158, 11, 0.1);
--warning-soft: #2a2518;
--danger-light: rgba(239, 68, 68, 0.1);
--danger-soft: #2a1a1a;
--info-light: rgba(59, 130, 246, 0.1);
--info-soft: #1a1e2a;
--muted-light: rgba(107, 114, 128, 0.15);
--orb-color-1: rgba(214, 48, 49, 0.2);
--orb-color-2: rgba(120, 119, 198, 0.15);
--calendar-icon-filter: invert(1);
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
--table-row-hover: rgba(255, 255, 255, 0.02);
--row-current: color-mix(in srgb, var(--success) 5%, transparent);
--row-current-hover: color-mix(in srgb, var(--success) 8%, transparent);
--row-draft: color-mix(in srgb, var(--warning) 6%, transparent);
--row-expired: color-mix(in srgb, var(--danger) 5%, transparent);
}
/* ---- Light theme ---- */
[data-theme="light"] {
--success: #15803d;
--warning: #b45309;
--danger: #b91c1c;
--info: #1d4ed8;
--accent-color: #c73030;
--accent-hover: #b52828;
--muted: #6b7280;
--bg-primary: #f5f4f2;
--bg-secondary: #ffffff;
--bg-tertiary: #eeecea;
--text-primary: #1a1a1a;
--text-secondary: #555555;
--text-muted: #717180;
--text-tertiary: #8a8a96;
--border-color: rgba(0, 0, 0, 0.1);
--border-color-hover: rgba(0, 0, 0, 0.18);
--glass-bg: #ffffff;
--glass-bg-solid: #ffffff;
--glass-border: rgba(0, 0, 0, 0.08);
--glass-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.04);
--card-bg: #ffffff;
--card-bg-hover: #ffffff;
--input-bg: #ffffff;
--glow-color: rgba(222, 58, 58, 0.08);
--accent-light: rgba(222, 58, 58, 0.08);
--accent-soft: #fff0f0;
--accent-glow: rgba(222, 58, 58, 0.15);
--success-light: rgba(34, 197, 94, 0.1);
--success-soft: #e8fbf7;
--warning-light: rgba(245, 158, 11, 0.1);
--warning-soft: #fef9ec;
--danger-light: rgba(239, 68, 68, 0.1);
--danger-soft: #fef2f2;
--info-light: rgba(59, 130, 246, 0.1);
--info-soft: #ebf3fd;
--muted-light: rgba(107, 114, 128, 0.12);
--calendar-icon-filter: none;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23555555' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
--table-row-hover: rgba(0, 0, 0, 0.03);
--row-current: var(--success-soft);
--row-current-hover: color-mix(in srgb, var(--success) 12%, transparent);
--row-draft: var(--warning-soft);
--row-expired: var(--danger-soft);
--orb-color-1: rgba(214, 48, 49, 0.12);
--orb-color-2: rgba(120, 119, 198, 0.1);
}
/* Light mode - jemnejsi stiny */
[data-theme="light"] .admin-toast {
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 3px rgba(0, 0, 0, 0.06);
}
[data-theme="light"] .react-datepicker {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1) !important;
}
[data-theme="light"] .admin-rich-editor .ql-snow .ql-picker-options,
[data-theme="light"] .admin-rich-editor .ql-snow .ql-tooltip {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .admin-customer-dropdown,
[data-theme="light"] .offers-template-menu {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .dash-quick-btn:hover {
filter: brightness(0.9);
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth"; import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response"; import { success, error, parseId } from "../../utils/response";
@@ -99,6 +100,9 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users --- // --- action=balances: leave balance overview for all users ---
if (action === "balances") { if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr); const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -106,6 +110,9 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview --- // --- action=workfund: monthly work fund overview ---
if (action === "workfund") { if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr); const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -113,6 +120,9 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours --- // --- action=project_report: monthly project hours ---
if (action === "project_report") { if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr); const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
@@ -132,6 +142,38 @@ export default async function attendanceRoutes(
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- 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: {
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
select: { id: true, first_name: true, last_name: true, username: true },
orderBy: { last_name: "asc" },
});
return reply.send({
success: true,
data: users.map((u) => ({
id: u.id,
first_name: u.first_name,
last_name: u.last_name,
username: u.username,
})),
});
}
// --- action=projects: active projects for attendance project switching --- // --- action=projects: active projects for attendance project switching ---
if (action === "projects") { if (action === "projects") {
const data = await attendanceService.getActiveProjects(); const data = await attendanceService.getActiveProjects();
@@ -140,6 +182,12 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record --- // --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") { if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id); const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Missing attendance_id", 400); if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId); const data = await attendanceService.getProjectLogs(attendanceId);
@@ -152,6 +200,10 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400); if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id); const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404); if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
return reply.send({ success: true, data: record }); return reply.send({ success: true, data: record });
} }
@@ -261,6 +313,14 @@ export default async function attendanceRoutes(
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400); if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data; const leaveBody = leaveParsed.data;
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createLeave( const result = await attendanceService.createLeave(
{ {
user_id: leaveBody.user_id, user_id: leaveBody.user_id,
@@ -309,6 +369,14 @@ export default async function attendanceRoutes(
if ("error" in stdParsed) return error(reply, stdParsed.error, 400); if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data; const body = stdParsed.data;
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createAttendance( const result = await attendanceService.createAttendance(
{ {
user_id: body.user_id, user_id: body.user_id,
@@ -331,6 +399,8 @@ export default async function attendanceRoutes(
}, },
authData.userId, authData.userId,
); );
if ("error" in result)
return error(reply, result.error!, result.status ?? 400);
await logAudit({ await logAudit({
request, request,
@@ -347,7 +417,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id // PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>( fastify.put<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("attendance.edit") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

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

View File

@@ -92,43 +92,101 @@ export default async function authRoutes(
// POST /api/admin/login/totp // POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>( fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp", "/login/totp",
{ bodyLimit: 10240 }, {
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body); const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data; const { login_token, totp_code, remember_me } = parsed.data;
const rawBody = request.body as unknown as Record<string, unknown>; const rememberMe = remember_me ?? false;
const rememberMe =
rawBody.remember_me === true || rawBody.remember_me === "true";
const tokenHash = crypto const tokenHash = crypto
.createHash("sha256") .createHash("sha256")
.update(login_token) .update(login_token)
.digest("hex"); .digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const totpResult = await prisma.$transaction(async (tx) => {
where: { token_hash: tokenHash }, const tokens = await tx.$queryRaw<
Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
// $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 (!storedToken || new Date(storedToken.expires_at) < new Date()) { if ("error" in totpResult) {
return error(reply, "Neplatný nebo expirovaný login token", 401); return error(reply, totpResult.error!, totpResult.status!);
} }
const user = await prisma.users.findUnique({ const user = totpResult.user;
where: { id: storedToken.user_id }, if (!user.totp_secret) {
include: { roles: true },
});
if (!user || !user.totp_secret) {
return error(reply, "Uživatel nenalezen", 401); return error(reply, "Uživatel nenalezen", 401);
} }
const isValid = OTPAuth.verify(user.totp_secret, totp_code); const verifyResult = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) { if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 401); return error(reply, "Neplatný TOTP kód", 401);
} }
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } }); // Reject replayed TOTP codes
const replayCheck = await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{ totp_last_used_counter: number | null }>
>`SELECT totp_last_used_counter FROM users WHERE id = ${user.id} FOR UPDATE`;
const lastCounter = rows[0]?.totp_last_used_counter ?? null;
if (
lastCounter !== null &&
verifyResult.counter !== null &&
verifyResult.counter <= lastCounter
) {
return { replay: true };
}
await tx.$executeRaw`UPDATE users SET totp_last_used_counter = ${verifyResult.counter} WHERE id = ${user.id}`;
return { replay: false };
});
if (replayCheck.replay) {
return error(reply, "TOTP kód již byl použit", 401);
}
// TOTP verified successfully — now consume the login token
await prisma.totp_login_tokens.delete({
where: { id: totpResult.storedTokenId },
});
// Reset failed attempts and update last login (TOTP verified = successful login) // Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({ await prisma.users.update({
@@ -181,39 +239,61 @@ export default async function authRoutes(
}); });
setRefreshCookie(reply, refreshTokenRaw, rememberMe); setRefreshCookie(reply, refreshTokenRaw, rememberMe);
await logAudit({
request,
authData: authData,
action: "login_totp",
entityType: "user",
entityId: user.id,
description: `TOTP přihlášení uživatele ${user.username}`,
});
return success(reply, { access_token: accessToken, user: authData }); return success(reply, { access_token: accessToken, user: authData });
}, },
); );
// POST /api/admin/refresh // POST /api/admin/refresh
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => { fastify.post(
const refreshTokenRaw = request.cookies.refresh_token; "/refresh",
if (!refreshTokenRaw) { {
return error(reply, "Refresh token chybí", 401); 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);
}
const result = await refreshAccessToken(refreshTokenRaw, request); const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === "error") { if (result.type === "error") {
reply.clearCookie("refresh_token", { reply.clearCookie("refresh_token", {
path: "/api/admin", path: "/api/admin",
httpOnly: true, httpOnly: true,
secure: config.isProduction, secure: config.isProduction,
sameSite: "strict", sameSite: "strict",
});
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
}); });
return error(reply, result.message, result.status); },
} );
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/logout // POST /api/admin/logout
fastify.post("/logout", async (request, reply) => { fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) { if (refreshTokenRaw) {
await logout(refreshTokenRaw); await logout(refreshTokenRaw);

View File

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

View File

@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
export default async function customersRoutes( export default async function customersRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { fastify.get(
const { page, limit, skip, sort, order, search } = parsePagination( "/",
request.query as Record<string, unknown>, { preHandler: requirePermission("customers.view") },
); async (request, reply) => {
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name"; const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
const where = search
? {
OR: [
{ name: { contains: search } },
{ company_id: { contains: search } },
],
}
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map((c) => {
const { custom_fields, customer_field_order } = decodeCustomFields(
c.custom_fields,
); );
return { const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
return reply.send({ const where = search
success: true, ? {
data: enriched, OR: [
pagination: buildPaginationMeta(total, page, limit), { name: { contains: search } },
}); { company_id: { contains: search } },
}); ],
}
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map((c) => {
const { custom_fields, customer_field_order } = decodeCustomFields(
c.custom_fields,
);
return {
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
return reply.send({
success: true,
data: enriched,
pagination: buildPaginationMeta(total, page, limit),
});
},
);
fastify.get<{ Params: { id: string } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("customers.view") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

@@ -3,6 +3,7 @@ import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth"; import { requireAuth } from "../../middleware/auth";
import { success } from "../../utils/response"; import { success } from "../../utils/response";
import { localTimeStr } from "../../utils/date"; import { localTimeStr } from "../../utils/date";
import { toCzk } from "../../services/exchange-rates";
export default async function dashboardRoutes( export default async function dashboardRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -141,8 +142,8 @@ export default async function dashboardRoutes(
const [openCount, convertedCount, expiredCount, createdThisMonth] = const [openCount, convertedCount, expiredCount, createdThisMonth] =
await Promise.all([ await Promise.all([
prisma.quotations.count({ where: { status: "active" } }), prisma.quotations.count({ where: { status: "active" } }),
prisma.quotations.count({ where: { status: "converted" } }), prisma.quotations.count({ where: { status: "ordered" } }),
prisma.quotations.count({ where: { status: "expired" } }), prisma.quotations.count({ where: { status: "invalidated" } }),
prisma.quotations.count({ prisma.quotations.count({
where: { created_at: { gte: monthStart, lt: monthEnd } }, where: { created_at: { gte: monthStart, lt: monthEnd } },
}), }),
@@ -178,40 +179,51 @@ export default async function dashboardRoutes(
// Invoices — only for invoices.view // Invoices — only for invoices.view
if (has("invoices.view")) { if (has("invoices.view")) {
const [unpaidCount, issuedThisMonth] = await Promise.all([ // $queryRaw template literal interpolation with Date objects fails on
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
const [unpaidCount, revenueAgg] = await Promise.all([
prisma.invoices.count({ where: { status: "issued" } }), prisma.invoices.count({ where: { status: "issued" } }),
prisma.invoices.findMany({ prisma.$queryRaw<
where: { issue_date: { gte: monthStart, lt: monthEnd } }, Array<{ currency: string | null; total: string | number | null }>
include: { invoice_items: true }, >`
}), SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total
FROM invoices i
JOIN invoice_items ii ON i.id = ii.invoice_id
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
GROUP BY i.currency
`,
]); ]);
const revenueByCurrency: Record<string, number> = {}; const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedThisMonth) { for (const row of revenueAgg) {
const currency = inv.currency ?? "CZK"; const currency = row.currency || "CZK";
let total = 0; const amount = Number(row.total) || 0;
for (const item of inv.invoice_items) {
total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
revenueByCurrency[currency] = revenueByCurrency[currency] =
(revenueByCurrency[currency] ?? 0) + total; (revenueByCurrency[currency] || 0) + amount;
} }
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 = { result.invoices = {
revenue_this_month: Object.entries(revenueByCurrency).map( revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
([currency, amount]) => ({ amount,
amount: Math.round(amount * 100) / 100, currency,
currency, })),
}),
),
unpaid_count: unpaidCount, unpaid_count: unpaidCount,
revenue_czk: revenue_czk:
revenueByCurrency["CZK"] != null Math.round(
? Math.round(revenueByCurrency["CZK"] * 100) / 100 revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
: null, ) / 100,
}; };
result.unpaid_invoices = unpaidCount;
} }
// Orders — only for orders.view // Orders — only for orders.view
@@ -227,7 +239,6 @@ export default async function dashboardRoutes(
where: { status: "pending" }, where: { status: "pending" },
}); });
result.leave_pending = { count }; result.leave_pending = { count };
result.pending_leave_requests = count;
} }
// Recent activity — only for settings.audit (admin) // Recent activity — only for settings.audit (admin)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -29,7 +29,7 @@ export default async function leaveRequestsRoutes(
const isAdmin = authData.permissions.includes("attendance.approve"); const isAdmin = authData.permissions.includes("attendance.approve");
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId; if (!isAdmin || query.mine === "1") where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id); else if (query.user_id) where.user_id = Number(query.user_id);
if (query.status) where.status = String(query.status); if (query.status) where.status = String(query.status);
@@ -241,57 +241,83 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8; const totalHours = totalBusinessDays * 8;
await prisma.$transaction(async (tx) => { try {
// 1. Create attendance records for each business day await prisma.$transaction(async (tx) => {
if (attendanceCreates.length > 0) { // Check for duplicate attendance records inside the transaction
await tx.attendance.createMany({ data: attendanceCreates }); for (const ac of attendanceCreates) {
} const duplicate = await tx.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
// 2. Update leave balance (vacation/sick only — not unpaid) });
if (leaveType === "vacation" || leaveType === "sick") { if (duplicate) {
const year = dateFrom.getFullYear(); throw new Error(
const existingBalance = await tx.leave_balances.findFirst({ "Pro zvolené datumy již existují záznamy docházky",
where: { user_id: existing.user_id, year }, );
});
if (existingBalance) {
const updateData: Record<string, unknown> = {
updated_at: new Date(),
};
if (leaveType === "vacation") {
updateData.vacation_used =
Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used =
Number(existingBalance.sick_used) + totalHours;
} }
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: updateData,
});
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalHours : 0,
sick_used: leaveType === "sick" ? totalHours : 0,
},
});
} }
}
// 3. Update request status // 1. Create attendance records for each business day
await tx.leave_requests.update({ if (attendanceCreates.length > 0) {
where: { id }, await tx.attendance.createMany({ data: attendanceCreates });
data: { }
status: "approved" as leave_requests_status,
reviewer_id: authData.userId, // 2. Update leave balance (vacation/sick only — not unpaid)
reviewed_at: new Date(), if (leaveType === "vacation" || leaveType === "sick") {
}, const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
if (existingBalance) {
const updateData: Record<string, unknown> = {
updated_at: new Date(),
};
if (leaveType === "vacation") {
updateData.vacation_used =
Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used =
Number(existingBalance.sick_used) + totalHours;
}
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: updateData,
});
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalHours : 0,
sick_used: leaveType === "sick" ? totalHours : 0,
},
});
}
}
// 3. Update request status
await tx.leave_requests.update({
where: { id },
data: {
status: "approved" as leave_requests_status,
reviewer_id: authData.userId,
reviewed_at: new Date(),
},
});
}); });
}); } catch (e) {
if (
e instanceof Error &&
e.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return error(
reply,
"Pro zvolené datumy již existují záznamy docházky",
400,
);
}
throw e;
}
await logAudit({ await logAudit({
request, request,
@@ -331,6 +357,7 @@ export default async function leaveRequestsRoutes(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ const existing = await prisma.leave_requests.findUnique({
@@ -342,6 +369,10 @@ export default async function leaveRequestsRoutes(
return error(reply, "Lze zrušit pouze čekající žádosti", 400); return error(reply, "Lze zrušit pouze čekající žádosti", 400);
} }
if (existing.user_id !== authData.userId) {
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
}
await prisma.leave_requests.update({ await prisma.leave_requests.update({
where: { id }, where: { id },
data: { status: "cancelled" }, data: { status: "cancelled" },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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