65 Commits

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

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

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

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

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

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

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

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

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

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

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

Moved all useCallback definitions before the conditional returns.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:17:20 +02:00
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
BOHA
cde560a2c3 1.3.3 2026-03-27 10:47:46 +01:00
BOHA
e6198e1b67 fix: file viewers blocked on mobile — open blank window before async fetch
Mobile browsers block window.open() after async operations. Changed all
file viewers to open a blank window synchronously in the click handler,
then set location.href after fetch completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:47:45 +01:00
BOHA
495fdf6da2 1.3.2 2026-03-27 10:42:29 +01:00
BOHA
7d29f40ab2 fix: offers table PDF button opens blob from NAS instead of print page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:42:29 +01:00
BOHA
6b9f1dee87 1.3.1 2026-03-27 10:33:15 +01:00
BOHA
687dcb9371 fix: OfferDetail uses default currency from system settings
The useEffect checked prev.currency === "EUR" but initial default was
changed to "CZK", so the settings default was never applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:33:15 +01:00
BOHA
9c49015968 1.3.0 2026-03-27 10:25:40 +01:00
BOHA
0021f5d46e 1.2.0 2026-03-27 10:16:56 +01:00
BOHA
16e48d4e5f chore: gitignore release archives and claude worktrees 2026-03-27 10:16:25 +01:00
BOHA
6b31b2f74b feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:15:47 +01:00
BOHA
f49015a627 1.1.4 2026-03-26 15:33:11 +01:00
BOHA
c201958689 fix: increase global rate limit from 100 to 300 req/min
Switching months quickly on received invoices triggered rate limit
due to multiple API calls per navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:33:10 +01:00
BOHA
461b54c4e6 1.1.3 2026-03-26 15:21:23 +01:00
BOHA
bdd58e70ff fix: flatten customer and user names in project detail response
Frontend expected flat customer_name and responsible_user_name but API
returned nested customers/users objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:21:22 +01:00
145 changed files with 13389 additions and 7902 deletions

View File

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

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@ dist/
*.log
dist-client/
*.css.map
# Release archives
*.tar.gz
# Claude worktrees
.claude/worktrees/
.claude/settings.local.json

302
CLAUDE.md Normal file
View File

@@ -0,0 +1,302 @@
# 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`
- Backup: `node_modules`, `.env`, `ecosystem.config.js`
- Clean directory (keep backups only)
- Extract tarball
- Restore backups
- Restart: `pm2 reload ecosystem.config.js`
Do not push directly to production or restart services without confirmation.

View File

@@ -19,7 +19,7 @@
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<title>BOHA | Admin</title>
<title>Admin</title>
</head>
<body>
<div id="root"></div>

586
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.1.2",
"version": "1.5.4",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -34,6 +34,7 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
@@ -42,7 +43,9 @@
"file-type": "^16.5.4",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.2",
"otpauth": "^9.5.0",
@@ -60,6 +63,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21",
"@types/mysql": "^2.15.27",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",

View File

@@ -0,0 +1 @@
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;

View File

@@ -0,0 +1,4 @@
ALTER TABLE `company_settings`
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE `company_settings`
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;

View File

@@ -0,0 +1,13 @@
-- System settings columns on company_settings
ALTER TABLE `company_settings`
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
ADD COLUMN `break_duration_short` INT DEFAULT 15,
ADD COLUMN `break_duration_long` INT DEFAULT 30,
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
ADD COLUMN `available_currencies` LONGTEXT NULL;

View File

@@ -0,0 +1,18 @@
-- Create new unified permission
INSERT INTO permissions (name, display_name, description, module)
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
-- Grant to all roles that had any of the old 3
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
-- Clean up old role_permissions
DELETE FROM role_permissions
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
-- Remove old permissions
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');

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

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date")
@@unique([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id")
}
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id")
@@ -100,16 +101,33 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes?
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText
available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
}
model customers {
@@ -148,7 +166,7 @@ model invoice_items {
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50)
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -179,6 +197,7 @@ model invoices {
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id")
}
@@ -236,6 +255,8 @@ model number_sequences {
type String? @db.VarChar(50)
year Int?
last_number Int? @default(0)
@@unique([type, year], map: "idx_number_sequences_type_year")
}
model order_items {
@@ -269,7 +290,7 @@ model order_sections {
model orders {
id Int @id @default(autoincrement())
order_number String? @db.VarChar(50)
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
customer_order_number String? @db.VarChar(100)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -321,7 +342,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -333,6 +354,7 @@ model projects {
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
@@ -366,7 +388,7 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50)
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -415,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int? @db.UnsignedInt
uploaded_by Int?
created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0)
@@ -427,7 +449,7 @@ model received_invoices {
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt
user_id Int @db.UnsignedInt
user_id Int
token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
@@ -561,6 +583,7 @@ model users {
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
@@ -607,6 +630,7 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,50 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
generateSharedNumber,
generateOfferNumber,
} from "../services/numbering.service";
import prisma from "../config/database";
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 yy = String(new Date().getFullYear()).slice(-2);
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
expect(typeof num).toBe("string");
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", () => {
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 year = new Date().getFullYear();
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
expect(typeof num).toBe("string");
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 Login from "./pages/Login";
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 "./dashboard.css";
import "./attendance.css";
@@ -32,7 +43,6 @@ const Offers = lazy(() => import("./pages/Offers"));
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const CompanySettings = lazy(() => import("./pages/CompanySettings"));
const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects"));
@@ -91,7 +101,6 @@ export default function AdminApp() {
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,7 @@
import { useMemo, useRef, useCallback } from "react";
import { useMemo, useRef, useCallback, useEffect } from "react";
import ReactQuill from "react-quill-new";
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 = [
"#000000",
"#1a1a1a",
@@ -95,8 +36,6 @@ const COLORS = [
];
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }],
[{ list: "ordered" }, { list: "bullet" }],
@@ -107,8 +46,6 @@ const TOOLBAR = [
];
const FORMATS = [
"font",
"size",
"bold",
"italic",
"underline",
@@ -159,9 +96,16 @@ export default function RichEditor({
[onChange],
);
useEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
}, []);
return (
<div
className="rich-editor"
className="admin-rich-editor"
style={{ "--re-min-height": minHeight } as React.CSSProperties}
>
<ReactQuill

View File

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

View File

@@ -330,22 +330,6 @@ const menuSections: MenuSection[] = [
</svg>
),
},
{
path: "/company/settings",
label: "Firma",
permission: "offers.settings",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
],
},
{
@@ -372,7 +356,7 @@ const menuSections: MenuSection[] = [
{
path: "/settings",
label: "Nastavení",
permission: ["settings.roles", "settings.security"],
permission: "settings.manage",
icon: (
<svg
viewBox="0 0 24 24"
@@ -464,11 +448,17 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
<img
src={
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png"
? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light"
}
alt="Logo"
className="admin-sidebar-logo"
onError={(e) => {
(e.target as HTMLImageElement).src =
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
}}
/>
<button
onClick={onClose}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
============================================================================ */
@@ -113,26 +13,6 @@
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 */
.dash-quick-actions {
display: grid;
@@ -512,16 +392,9 @@
display: grid;
grid-template-columns: 1fr 1fr;
}
.dash-kpi-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.dash-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.dash-quick-actions {
grid-template-columns: repeat(2, 1fr);
}
@@ -543,9 +416,6 @@
.dash-quick-actions {
grid-template-columns: 1fr 1fr;
}
.dash-kpi-grid {
grid-template-columns: 1fr;
}
.dash-profile-grid {
grid-template-columns: 1fr;
}
@@ -555,12 +425,12 @@
Sessions / Devices
============================================================================ */
.sessions-list {
.dash-sessions-list {
display: flex;
flex-direction: column;
}
.session-item {
.dash-session-item {
display: flex;
align-items: center;
gap: 1rem;
@@ -569,23 +439,23 @@
transition: var(--transition);
}
.session-item:last-child {
.dash-session-item:last-child {
border-bottom: none;
}
.session-item:hover {
.dash-session-item:hover {
background: var(--bg-tertiary);
}
.session-item-current {
.dash-session-item-current {
background: var(--row-current);
}
.session-item-current:hover {
.dash-session-item-current:hover {
background: var(--row-current-hover);
}
.session-icon {
.dash-session-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
@@ -597,17 +467,17 @@
flex-shrink: 0;
}
.session-item-current .session-icon {
.dash-session-item-current .dash-session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.session-info {
.dash-session-info {
flex: 1;
min-width: 0;
}
.session-device {
.dash-session-device {
font-weight: 500;
color: var(--text-primary);
display: flex;
@@ -616,7 +486,7 @@
gap: 0.25rem;
}
.session-meta {
.dash-session-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
@@ -626,30 +496,30 @@
gap: 0.5rem;
}
.session-meta-separator {
.dash-session-meta-separator {
color: var(--border-color);
}
.session-actions {
.dash-session-actions {
flex-shrink: 0;
}
@media (max-width: 640px) {
.session-item {
.dash-session-item {
padding: 1rem;
gap: 0.75rem;
}
.session-icon {
.dash-session-icon {
width: 36px;
height: 36px;
}
.session-device {
.dash-session-device {
font-size: 0.875rem;
}
.session-meta {
.dash-session-meta {
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;
try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, {
...options,
...restOptions,
signal: controller.signal,
});
const data = await response.json();

View File

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

View File

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

View File

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

View File

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

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
============================================ */
/* 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 */
.offers-items-table {
overflow-x: auto;
@@ -103,213 +46,6 @@
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 */
.offers-template-menu {
position: absolute;
@@ -361,81 +97,20 @@
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) */
.rich-editor {
.admin-rich-editor {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
}
.rich-editor .quill {
.admin-rich-editor .quill {
display: flex;
flex-direction: column;
}
/* Toolbar */
.rich-editor .ql-toolbar.ql-snow {
.admin-rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary);
border: none;
border-bottom: 1px solid var(--border-color);
@@ -445,60 +120,60 @@
gap: 2px;
}
.rich-editor .ql-toolbar .ql-formats {
.admin-rich-editor .ql-toolbar .ql-formats {
margin-right: 8px;
}
/* Toolbar buttons */
.rich-editor .ql-snow .ql-stroke {
.admin-rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary);
}
.rich-editor .ql-snow .ql-fill {
.admin-rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary);
}
.rich-editor .ql-snow .ql-picker-label {
.admin-rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary);
border-color: var(--border-color);
}
.rich-editor .ql-snow button:hover .ql-stroke,
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
.admin-rich-editor .ql-snow button:hover .ql-stroke,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary);
}
.rich-editor .ql-snow button:hover .ql-fill,
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
.admin-rich-editor .ql-snow button:hover .ql-fill,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary);
}
.rich-editor .ql-snow button:hover,
.rich-editor .ql-snow .ql-picker-label:hover {
.admin-rich-editor .ql-snow button:hover,
.admin-rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary);
}
/* Active state */
.rich-editor .ql-snow button.ql-active {
.admin-rich-editor .ql-snow button.ql-active {
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
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);
}
.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-fill,
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
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);
}
.rich-editor .ql-snow .ql-picker-label.ql-active {
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
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);
}
/* Dropdowns (font, size, color, align) */
.rich-editor .ql-snow .ql-picker-options {
.admin-rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
@@ -507,23 +182,23 @@
padding: 0.25rem;
}
.rich-editor .ql-snow .ql-picker-item {
.admin-rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
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);
background: var(--bg-secondary);
}
/* Font picker */
.rich-editor .ql-snow .ql-font .ql-picker-options {
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
min-width: 11rem;
max-height: 200px;
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;
overflow-y: auto;
}
@@ -703,34 +378,39 @@
}
/* Editor area */
.rich-editor .ql-container.ql-snow {
.admin-rich-editor .ql-container.ql-snow {
border: none;
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);
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-size: 0.875rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg);
}
.rich-editor .ql-editor.ql-blank::before {
.admin-rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
/* Lists inside editor */
.rich-editor .ql-editor ul,
.rich-editor .ql-editor ol {
.admin-rich-editor .ql-editor ul,
.admin-rich-editor .ql-editor ol {
padding-left: 1.5rem;
}
/* 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;
padding: 0.375rem;
display: flex;
@@ -738,7 +418,7 @@
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;
height: 18px;
border-radius: 2px;
@@ -748,7 +428,7 @@
}
/* Tooltip (link editor) */
.rich-editor .ql-snow .ql-tooltip {
.admin-rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
@@ -756,7 +436,7 @@
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);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
@@ -764,12 +444,12 @@
padding: 0.25rem 0.5rem;
}
.rich-editor .ql-snow .ql-tooltip a {
.admin-rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color);
}
/* Read-only rendered rich text (Quill HTML output) */
.rich-text-view {
.admin-rich-text-view {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.875rem;
@@ -778,56 +458,44 @@
min-width: 0;
}
.rich-text-view ul,
.rich-text-view ol {
.admin-rich-text-view ul,
.admin-rich-text-view ol {
padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem;
}
.rich-text-view li {
.admin-rich-text-view li {
margin-bottom: 0.15rem;
}
.rich-text-view a {
.admin-rich-text-view a {
color: var(--accent-color);
}
.rich-text-view strong,
.rich-text-view b {
.admin-rich-text-view strong,
.admin-rich-text-view b {
font-weight: 600;
color: var(--text-primary);
display: inline-block;
margin-top: 0.5rem;
}
.rich-text-view br + b,
.rich-text-view br + strong {
.admin-rich-text-view br + b,
.admin-rich-text-view br + strong {
margin-top: 0.75rem;
}
.rich-text-view > br:first-child,
.rich-text-view ul + br,
.rich-text-view ol + br {
.admin-rich-text-view > br:first-child,
.admin-rich-text-view ul + br,
.admin-rich-text-view ol + br {
display: none;
}
@media (max-width: 640px) {
.offers-editor-section {
padding: 1rem;
}
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
}
.offers-totals-summary {
align-items: stretch;
}
.offers-totals-row {
min-width: unset;
}
}
/* Offer draft row in table */

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,73 @@ const formatBreakRange = (record: AttendanceRecord): string => {
return "—";
};
function getEasterSunday(year: number): string {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function getCzechHolidays(year: number): string[] {
const y = String(year);
const holidays = [
`${y}-01-01`,
`${y}-05-01`,
`${y}-05-08`,
`${y}-07-05`,
`${y}-07-06`,
`${y}-09-28`,
`${y}-10-28`,
`${y}-11-17`,
`${y}-12-24`,
`${y}-12-25`,
`${y}-12-26`,
];
const easterSunday = getEasterSunday(year);
const easterDate = new Date(easterSunday);
const goodFriday = new Date(easterDate);
goodFriday.setDate(goodFriday.getDate() - 2);
const easterMonday = new Date(easterDate);
easterMonday.setDate(easterMonday.getDate() + 1);
const pad = (n: number) => String(n).padStart(2, "0");
holidays.push(
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
);
holidays.push(
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
);
holidays.sort();
return holidays;
}
function getBusinessDaysInMonth(year: number, month: number): number {
const holidays = getCzechHolidays(year);
let count = 0;
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (!holidays.includes(dateStr)) {
count++;
}
}
}
return count;
}
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
@@ -85,8 +152,11 @@ const renderProjectCell = (record: AttendanceRecord) => {
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;
@@ -126,6 +196,7 @@ export default function AttendanceHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
@@ -156,6 +227,15 @@ export default function AttendanceHistory() {
fetchData();
}, [fetchData]);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanyName(d.data.company_name || "");
})
.catch(() => {});
}, []);
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");
const monthIndex = parseInt(monthStr, 10) - 1;
@@ -172,7 +252,8 @@ export default function AttendanceHistory() {
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours = Number(record.leave_hours) || 8;
const hours =
record.leave_hours != null ? Number(record.leave_hours) : 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
@@ -180,21 +261,9 @@ export default function AttendanceHistory() {
}
}
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const holidayDays = records.filter(
(r) => (r.leave_type || "work") === "holiday",
).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1);
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays);
const businessDays = getBusinessDaysInMonth(yr, mo);
const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
@@ -637,13 +706,13 @@ export default function AttendanceHistory() {
<div className="print-header">
<div className="print-header-left">
<img
src="/images/logo-light.png"
alt="BOHA"
src="/api/admin/company-settings/logo?variant=light"
alt=""
className="print-logo"
/>
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
<div className="company">{companyName}</div>
</div>
</div>
<div className="print-header-right">

View File

@@ -5,12 +5,13 @@ import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
declare const L: any;
interface LocationRecord {
user_name: string;
shift_date: string;
@@ -74,23 +75,6 @@ export default function AttendanceLocation() {
if (!hasAnyLocation || !mapRef.current) return;
const loadLeaflet = async () => {
if ((window as unknown as Record<string, unknown>).L) {
initMap();
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
const script = document.createElement("script");
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.onload = initMap;
document.body.appendChild(script);
};
const initMap = () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove();
@@ -150,9 +134,17 @@ export default function AttendanceLocation() {
fillOpacity: 0.8,
}).addTo(map);
marker.bindPopup(
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`,
const popupEl = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = loc.label;
popupEl.appendChild(strong);
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(document.createTextNode(loc.time));
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(
document.createTextNode(`Přesnost: ${Math.round(loc.accuracy)}m`),
);
marker.bindPopup(popupEl);
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
@@ -175,7 +167,7 @@ export default function AttendanceLocation() {
}
};
loadLeaflet();
initMap();
return () => {
if (mapInstanceRef.current) {

View File

@@ -3,6 +3,7 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
@@ -24,8 +25,6 @@ const FIELD_LABELS: Record<string, string> = {
vat_id: "DIČ",
};
const currentYear = new Date().getFullYear().toString().slice(-2);
interface CustomField {
name: string;
value: string;
@@ -41,11 +40,6 @@ interface CompanyForm {
country: string;
company_id: string;
vat_id: string;
quotation_prefix: string;
default_currency: string;
default_vat_rate: number;
order_type_code: string;
invoice_type_code: string;
}
interface BankAccount {
@@ -69,14 +63,19 @@ interface BankForm {
is_default: boolean;
}
export default function CompanySettings() {
export default function CompanySettings({
embedded,
}: { embedded?: boolean } = {}) {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingLogoDark, setUploadingLogoDark] = useState(false);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
const logoUrlRef = useRef<string | null>(null);
const logoUrlDarkRef = useRef<string | null>(null);
const [form, setForm] = useState<CompanyForm>({
company_name: "",
street: "",
@@ -85,21 +84,25 @@ export default function CompanySettings() {
country: "",
company_id: "",
vat_id: "",
quotation_prefix: "N",
default_currency: "EUR",
default_vat_rate: 21,
order_type_code: "71",
invoice_type_code: "81",
});
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const customFieldKeyCounter = useRef(0);
const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER,
]);
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
"CZK",
"EUR",
"USD",
"GBP",
]);
const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
isOpen: boolean;
id: number | null;
}>({ isOpen: false, id: null });
const [bankForm, setBankForm] = useState<BankForm>({
account_name: "",
bank_name: "",
@@ -151,17 +154,28 @@ export default function CompanySettings() {
return key;
};
const fetchLogo = useCallback(async () => {
const fetchLogo = useCallback(async (variant: "light" | "dark" = "light") => {
try {
const resp = await apiFetch(`${API_BASE}/company-settings/logo`);
const resp = await apiFetch(
`${API_BASE}/company-settings/logo?variant=${variant}`,
);
if (resp.ok) {
const blob = await resp.blob();
setLogoUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
const url = URL.createObjectURL(blob);
logoUrlRef.current = url;
return url;
});
if (variant === "dark") {
setLogoUrlDark((prev) => {
if (prev) URL.revokeObjectURL(prev);
const url = URL.createObjectURL(blob);
logoUrlDarkRef.current = url;
return url;
});
} else {
setLogoUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
const url = URL.createObjectURL(blob);
logoUrlRef.current = url;
return url;
});
}
}
} catch {
// ignore - no logo
@@ -183,18 +197,21 @@ export default function CompanySettings() {
country: d.country || "",
company_id: d.company_id || "",
vat_id: d.vat_id || "",
quotation_prefix: d.quotation_prefix || "N",
default_currency: d.default_currency || "EUR",
default_vat_rate: d.default_vat_rate || 21,
order_type_code: d.order_type_code || "71",
invoice_type_code: d.invoice_type_code || "81",
});
const cf =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map(
(f: { name: string; value: string; showLabel?: boolean }) => ({
(
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f,
_key: `cf-${++customFieldKeyCounter.current}`,
_key: f._key || `cf-${Date.now()}-${i}`,
}),
)
: [];
@@ -207,8 +224,17 @@ export default function CompanySettings() {
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies);
}
if (d.has_logo) {
fetchLogo();
fetchLogo("light");
}
if (d.has_logo_dark) {
fetchLogo("dark");
}
} else {
alert.error(result.error || "Nepodařilo se načíst nastavení");
@@ -279,22 +305,31 @@ export default function CompanySettings() {
}
};
const handleBankDelete = async (id: number) => {
if (!confirm("Opravdu smazat tento bankovní účet?")) return;
const handleBankDelete = (id: number) => {
setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, {
method: "DELETE",
});
const response = await apiFetch(
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message);
if (editingBank === id) resetBankForm();
if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts();
} else {
alert.error(result.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
}
};
@@ -316,10 +351,11 @@ export default function CompanySettings() {
fetchBankAccounts();
}, [fetchData, fetchBankAccounts]);
// Cleanup blob URL on unmount
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current);
if (logoUrlDarkRef.current) URL.revokeObjectURL(logoUrlDarkRef.current);
};
}, []);
@@ -351,30 +387,38 @@ export default function CompanySettings() {
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
variant: "light" | "dark" = "light",
) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
const setUploading =
variant === "dark" ? setUploadingLogoDark : setUploadingLogo;
setUploading(true);
try {
const formData = new FormData();
formData.append("logo", file);
const response = await apiFetch(`${API_BASE}/company-settings/logo`, {
method: "POST",
body: formData,
});
const response = await apiFetch(
`${API_BASE}/company-settings/logo?variant=${variant}`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
fetchLogo();
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
}
} catch {
alert.error("Chyba připojení");
} finally {
setUploadingLogo(false);
setUploading(false);
e.target.value = "";
}
};
@@ -383,7 +427,7 @@ export default function CompanySettings() {
setForm((prev) => ({ ...prev, [field]: value }));
};
if (!hasPermission("offers.settings")) return <Forbidden />;
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (loading) {
return (
@@ -464,35 +508,35 @@ export default function CompanySettings() {
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nastavení firmy</h1>
<p className="admin-page-subtitle">
Firemní údaje, číslování dokladů a výchozí hodnoty
</p>
</div>
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
{!embedded && (
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit nastavení"
)}
</button>
</motion.div>
<div>
<h1 className="admin-page-title">Nastavení firmy</h1>
<p className="admin-page-subtitle">Firemní údaje a bankovní účty</p>
</div>
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit nastavení"
)}
</button>
</motion.div>
)}
<div className="offers-settings-grid">
<div className="admin-settings-grid">
{/* Company Info */}
<motion.div
className="admin-card"
@@ -693,7 +737,7 @@ export default function CompanySettings() {
name: "",
value: "",
showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`,
_key: `cf-${Date.now()}`,
},
])
}
@@ -898,10 +942,11 @@ export default function CompanySettings() {
}
className="admin-form-select"
>
<option value="CZK">CZK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
{availableCurrencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
</div>
@@ -1055,177 +1100,146 @@ export default function CompanySettings() {
<h3 className="admin-card-title">Logo</h3>
</div>
<div className="admin-card-body">
<div className="offers-logo-section">
{logoUrl && (
<div className="offers-logo-preview">
<img src={logoUrl} alt="Logo" />
</div>
)}
<label
className="admin-btn admin-btn-secondary"
style={{ cursor: "pointer" }}
>
{uploadingLogo ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát logo
</>
<div className="admin-form-row">
<div className="admin-logo-section">
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
Logo (světlý režim)
</label>
{logoUrl && (
<div className="admin-logo-preview">
<img src={logoUrl} alt="Logo (světlý režim)" />
</div>
)}
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
style={{ display: "none" }}
disabled={uploadingLogo}
/>
</label>
<small className="admin-form-hint">
PNG, JPEG, GIF nebo WebP, max 5 MB
</small>
</div>
</div>
</motion.div>
{/* Cislovani dokladu */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Číslování dokladů</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Nabídky — prefix">
<input
type="text"
value={form.quotation_prefix}
onChange={(e) =>
updateField("quotation_prefix", e.target.value)
}
className="admin-form-input"
placeholder="N"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: ROK/PREFIX/ČÍSLO ukázka: {new Date().getFullYear()}/
{form.quotation_prefix || "N"}/001
</small>
</FormField>
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "0.75rem 0",
}}
/>
<FormField label="Objednávky a projekty — typový kód">
<input
type="text"
value={form.order_type_code}
onChange={(e) =>
updateField("order_type_code", e.target.value)
}
className="admin-form-input"
placeholder="71"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}
{form.order_type_code || "71"}0001
</small>
</FormField>
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "0.75rem 0",
}}
/>
<FormField label="Faktury — typový kód">
<input
type="text"
value={form.invoice_type_code}
onChange={(e) =>
updateField("invoice_type_code", e.target.value)
}
className="admin-form-input"
placeholder="81"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}
{form.invoice_type_code || "81"}0001
</small>
</FormField>
</div>
</div>
</motion.div>
{/* Default values */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Výchozí hodnoty</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={form.default_currency}
onChange={(e) =>
updateField("default_currency", e.target.value)
}
className="admin-form-select"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CZK">CZK</option>
<option value="GBP">GBP</option>
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<label
className="admin-btn admin-btn-secondary"
style={{ cursor: "pointer" }}
>
{uploadingLogo ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát logo
</>
)}
<input
type="number"
value={form.default_vat_rate}
onChange={(e) =>
updateField(
"default_vat_rate",
parseFloat(e.target.value) || 0,
)
}
className="admin-form-input"
step="0.1"
type="file"
accept="image/*"
onChange={(e) => handleLogoUpload(e, "light")}
style={{ display: "none" }}
disabled={uploadingLogo}
/>
</FormField>
</label>
<small className="admin-form-hint">
PNG, JPEG, GIF nebo WebP, max 5 MB
</small>
</div>
<div className="admin-logo-section">
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
Logo (tmavý režim)
</label>
{logoUrlDark && (
<div className="admin-logo-preview">
<img src={logoUrlDark} alt="Logo (tmavý režim)" />
</div>
)}
<label
className="admin-btn admin-btn-secondary"
style={{ cursor: "pointer" }}
>
{uploadingLogoDark ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát logo
</>
)}
<input
type="file"
accept="image/*"
onChange={(e) => handleLogoUpload(e, "dark")}
style={{ display: "none" }}
disabled={uploadingLogoDark}
/>
</label>
<small className="admin-form-hint">
PNG, JPEG, GIF nebo WebP, max 5 MB
</small>
</div>
</div>
</div>
</motion.div>
</div>
{embedded && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.2 }}
>
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
style={{ width: "100%", marginTop: "1rem" }}
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit nastavení firmy"
)}
</button>
</motion.div>
)}
<ConfirmModal
isOpen={bankDeleteConfirm.isOpen}
onClose={() => setBankDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmBankDelete}
title="Smazat bankovní účet"
message="Opravdu chcete smazat tento bankovní účet?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import {
} from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
@@ -56,7 +56,7 @@ function formatCzkWithDetail(
if (!Array.isArray(amounts) || amounts.length === 0)
return { value: "0 Kč", detail: null };
const hasForeign = amounts.some((a) => a.currency !== "CZK");
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
if (hasForeign && totalCzk != null) {
return {
value: formatCurrency(totalCzk, "CZK"),
detail: formatMultiCurrency(amounts),
@@ -119,7 +119,11 @@ export default function Invoices() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState("issued");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab =
searchParams.get("tab") === "received" ? "received" : "issued";
const setActiveTab = (tab: string) =>
setSearchParams({ tab }, { replace: true });
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
const { sort, order, handleSort, activeSort } =
useTableSort("invoice_number");
@@ -134,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -190,7 +204,6 @@ export default function Invoices() {
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [langModal, setLangModal] = useState<Invoice | null>(null);
const [draft, setDraft] = useState<DraftData | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY);
@@ -280,29 +293,27 @@ export default function Invoices() {
}
};
const handlePdf = async (inv: Invoice, lang = "cs") => {
const handlePdf = async (inv: Invoice) => {
if (pdfLoading) return;
setLangModal(null);
const newWindow = window.open("", "_blank");
setPdfLoading(inv.id);
try {
const response = await apiFetch(
`${API_BASE}/invoices-pdf/${inv.id}?lang=${encodeURIComponent(lang)}`,
);
if (response.status === 401) return;
if (!response.ok) {
alert.error("Nepodařilo se vygenerovat PDF");
const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`);
if (response.status === 401) {
newWindow?.close();
return;
}
const html = await response.text();
const w = window.open("", "_blank");
if (w) {
w.document.open();
w.document.write(html);
w.document.close();
w.onload = () => w.print();
} else {
alert.error("Prohlížeč zablokoval vyskakovací okno");
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji");
return;
}
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 {
alert.error("Chyba při generování PDF");
} finally {
@@ -330,7 +341,7 @@ export default function Invoices() {
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="dash-kpi-grid dash-kpi-4">
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
@@ -493,15 +504,15 @@ export default function Invoices() {
</button>
</div>
<div className="offers-tabs mb-4" style={{ justifyContent: "center" }}>
<div className="admin-tabs mb-4" style={{ justifyContent: "center" }}>
<button
className={`offers-tab ${activeTab === "issued" ? "active" : ""}`}
className={`admin-tab ${activeTab === "issued" ? "active" : ""}`}
onClick={() => setActiveTab("issued")}
>
Vydané
</button>
<button
className={`offers-tab ${activeTab === "received" ? "active" : ""}`}
className={`admin-tab ${activeTab === "received" ? "active" : ""}`}
onClick={() => setActiveTab("received")}
>
Přijaté
@@ -518,7 +529,7 @@ export default function Invoices() {
<Suspense
fallback={
<div
className="dash-kpi-grid dash-kpi-4"
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
@@ -565,7 +576,7 @@ export default function Invoices() {
>
{!hasLoadedOnce.current && statsLoading ? (
<div
className="dash-kpi-grid dash-kpi-4"
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
@@ -603,7 +614,7 @@ export default function Invoices() {
>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
className="admin-kpi-grid admin-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({
@@ -736,11 +747,11 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="offers-tabs mb-6">
<div className="admin-tabs mb-6">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
className={`offers-tab ${statusFilter === f.value ? "active" : ""}`}
className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
onClick={() => {
setStatusFilter(f.value);
setPage(1);
@@ -992,26 +1003,44 @@ export default function Invoices() {
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title="Detail"
aria-label="Detail"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
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" />
</svg>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
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" />
</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>
{hasPermission("invoices.export") && (
<button
onClick={() => setLangModal(inv)}
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="PDF"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
@@ -1088,69 +1117,6 @@ export default function Invoices() {
type="danger"
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>

View File

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

View File

@@ -61,7 +61,7 @@ export default function LeaveRequests() {
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests`);
const response = await apiFetch(`${API_BASE}/leave-requests?mine=1`);
if (response.status === 401) return;
const result = await response.json();
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 { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni.");
}
}, [alert]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input
useEffect(() => {
@@ -43,6 +44,92 @@ export default function Login() {
}
}, [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) {
return (
<div className="admin-login">
@@ -57,63 +144,6 @@ export default function Login() {
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 (
<motion.div
className="admin-login"
@@ -190,11 +220,17 @@ export default function Login() {
<img
src={
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png"
? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light"
}
alt="Logo"
className="admin-login-logo"
onError={(e) => {
(e.target as HTMLImageElement).src =
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
}}
/>
<h1 className="admin-login-title">Interní systém</h1>
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>

View File

@@ -2,6 +2,7 @@ import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ChangeEvent,
} from "react";
@@ -55,9 +56,6 @@ interface OfferItem {
is_included_in_total: boolean;
}
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection {
title: string;
title_cz: string;
@@ -98,7 +96,7 @@ const emptyForm: OfferForm = {
customer_name: "",
created_at: new Date().toISOString().split("T")[0],
valid_until: "",
currency: "EUR",
currency: "CZK",
language: "EN",
vat_rate: 21,
apply_vat: false,
@@ -113,16 +111,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "",
});
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({
item,
index,
@@ -180,9 +168,7 @@ function SortableItemRow({
</button>
</td>
)}
<td style={{ textAlign: "center", color: "var(--text-tertiary)" }}>
{index + 1}
</td>
<td className="text-center text-tertiary">{index + 1}</td>
<td style={{ verticalAlign: "top" }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
@@ -191,10 +177,9 @@ function SortableItemRow({
type="text"
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
className="admin-form-input"
className="admin-form-input fw-500"
placeholder="Název položky"
readOnly={readOnly}
style={{ fontWeight: 500 }}
/>
<input
type="text"
@@ -240,7 +225,7 @@ function SortableItemRow({
readOnly={readOnly}
/>
</td>
<td style={{ textAlign: "center" }}>
<td className="text-center">
<input
type="checkbox"
checked={item.is_included_in_total}
@@ -248,10 +233,7 @@ function SortableItemRow({
disabled={readOnly}
/>
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 600 }}
>
<td className="admin-mono text-right fw-600">
{formatCurrency(lineTotal, currency)}
</td>
{!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() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -294,12 +289,56 @@ export default function OfferDetail() {
useSensor(KeyboardSensor),
);
const itemKeyCounter = useRef(0);
const emptyItem = useCallback(
(): OfferItem => ({
_key: `item-${++itemKeyCounter.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
}),
[],
);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>([emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [form, setForm] = useState<OfferForm>(() => {
const draft = loadOfferDraft();
if (draft?.form) {
return {
...emptyForm,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
return draft.items as OfferItem[];
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
id: number;
@@ -327,15 +366,54 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} | null>(null);
const [lockedBy, setLockedBy] = useState<{
user_id: number;
username: string;
full_name: string;
} | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
useModalLock(showOrderModal);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) {
setCompanySettings(d.data);
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(() => {});
}, []);
const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated =
@@ -353,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
const formData = {
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
@@ -362,28 +440,35 @@ export default function OfferDetail() {
valid_until: d.valid_until
? String(d.valid_until).substring(0, 10)
: "",
currency: d.currency || "EUR",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? 21,
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
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 || "");
setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null);
@@ -408,7 +493,7 @@ export default function OfferDetail() {
} finally {
setLoading(false);
}
}, [id, alert, navigate, hasPermission]);
}, [id, alert, navigate, hasPermission, companySettings]);
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
@@ -422,10 +507,14 @@ export default function OfferDetail() {
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
// Release lock on unmount
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch(
() => {},
);
const controller = new AbortController();
unlockAbortRef.current = controller;
apiFetch(`${API_BASE}/offers/${id}/unlock`, {
method: "POST",
signal: controller.signal,
}).catch(() => {});
};
}, [isEdit, id, isLockedByOther, isInvalidated]);
@@ -433,6 +522,28 @@ export default function OfferDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
useEffect(() => {
const loadCustomers = async () => {
try {
@@ -492,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber();
}, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -610,14 +688,17 @@ export default function OfferDetail() {
setSaving(true);
try {
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, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
}),
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
@@ -641,6 +722,13 @@ export default function OfferDetail() {
if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
}
@@ -737,19 +825,26 @@ export default function OfferDetail() {
const handlePdf = async () => {
if (!isEdit || pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}/file`);
if (response.status === 401) return;
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba při generování PDF");
} finally {
setPdfLoading(false);
@@ -837,11 +932,10 @@ export default function OfferDetail() {
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
{isInvalidated && (
<span
className="admin-badge admin-badge-danger"
className="admin-badge admin-badge-danger text-xs"
style={{
marginLeft: "0.75rem",
verticalAlign: "middle",
fontSize: "0.75rem",
}}
>
Zneplatněna
@@ -974,25 +1068,24 @@ export default function OfferDetail() {
{/* Quotation Form */}
<motion.div
className={`offers-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="offers-form-row-3">
<div className="admin-form-row admin-form-row-3">
<FormField label="Číslo nabídky">
<input
type="text"
value={form.quotation_number}
onChange={(e) =>
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
readOnly
className="admin-form-input"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Kód projektu">
@@ -1007,7 +1100,7 @@ export default function OfferDetail() {
</FormField>
<FormField label="Zákazník" error={errors.customer_id}>
{form.customer_id ? (
<div className="offers-customer-selected">
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && (
<button
@@ -1032,7 +1125,7 @@ export default function OfferDetail() {
</div>
) : (
<div
className="offers-customer-select"
className="admin-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
@@ -1048,16 +1141,16 @@ export default function OfferDetail() {
readOnly={isInvalidated || isLockedByOther}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="offers-customer-dropdown">
<div className="admin-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
<div className="admin-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="offers-customer-dropdown-item"
className="admin-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
@@ -1125,10 +1218,18 @@ export default function OfferDetail() {
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CZK">CZK</option>
<option value="GBP">GBP</option>
{(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Jazyk nabídky">
@@ -1144,23 +1245,26 @@ export default function OfferDetail() {
</FormField>
</div>
<div className="offers-form-row-3">
<div className="admin-form-row admin-form-row-3">
<FormField label="Sazba DPH (%)">
<div className="flex-row-gap">
<input
type="number"
<select
value={form.vat_rate}
onChange={(e) =>
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-input flex-1"
step="0.1"
readOnly={isInvalidated || isLockedByOther}
/>
<label
className="admin-form-checkbox"
style={{ whiteSpace: "nowrap" }}
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
>
{(
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((r) => (
<option key={r} value={r}>
{r}%
</option>
))}
</select>
<label className="admin-form-checkbox whitespace-nowrap">
<input
type="checkbox"
checked={form.apply_vat}
@@ -1281,18 +1385,18 @@ export default function OfferDetail() {
</div>
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<div className="admin-totals-summary">
<div className="admin-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(subtotal, form.currency)}</span>
</div>
{form.apply_vat && (
<div className="offers-totals-row">
<div className="admin-totals-row">
<span>DPH ({form.vat_rate}%):</span>
<span>{formatCurrency(vatAmount, form.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<div className="admin-totals-row admin-totals-total">
<span>Celkem:</span>
<span>{formatCurrency(total, form.currency)}</span>
</div>
@@ -1621,7 +1725,7 @@ export default function OfferDetail() {
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span style={{ fontSize: "0.875rem" }}>
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(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 { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
@@ -63,6 +63,16 @@ export default function Offers() {
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
@@ -221,26 +231,30 @@ export default function Offers() {
const handlePdf = async (quotation: Quotation) => {
if (pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(quotation.id);
try {
const response = await apiFetch(`${API_BASE}/offers-pdf/${quotation.id}`);
if (response.status === 401) return;
if (!response.ok) {
alert.error("Nepodařilo se vygenerovat PDF");
const response = await apiFetch(
`${API_BASE}/offers/${quotation.id}/file`,
);
if (response.status === 401) {
newWindow?.close();
return;
}
const html = await response.text();
const w = window.open("", "_blank");
if (w) {
w.document.open();
w.document.write(html);
w.document.close();
w.onload = () => w.print();
} else {
alert.error("Prohlížeč zablokoval vyskakovací okno");
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
return;
}
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 {
alert.error("Chyba při generování PDF");
newWindow?.close();
alert.error("Chyba připojení");
} finally {
setPdfLoading(null);
}
@@ -324,7 +338,7 @@ export default function Offers() {
</p>
</div>
<div className="admin-page-actions">
{hasPermission("offers.settings") && (
{hasPermission("settings.manage") && (
<Link
to="/offers/templates"
className="admin-btn admin-btn-secondary"
@@ -753,7 +767,7 @@ export default function Offers() {
<button
onClick={() => handlePdf(q)}
className="admin-btn-icon"
title="PDF"
title="Zobrazit nabídku"
disabled={pdfLoading === q.id}
>
{pdfLoading === q.id ? (
@@ -812,8 +826,8 @@ export default function Offers() {
<tr>
<td
colSpan={8}
className="text-muted"
style={{ textAlign: "center", padding: "1.5rem" }}
className="text-muted text-center"
style={{ padding: "1.5rem" }}
>
Žádné nabídky odpovídající hledání.
</td>
@@ -877,8 +891,8 @@ export default function Offers() {
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
<p
className="text-secondary"
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
className="text-secondary text-md"
style={{ marginTop: "0.25rem" }}
>
Nabídka:{" "}
<strong>{orderModal.quotation?.quotation_number}</strong>
@@ -915,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" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span style={{ fontSize: "0.875rem" }}>
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
@@ -942,11 +956,9 @@ export default function Offers() {
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
className="admin-btn admin-btn-secondary admin-btn-sm inline-flex"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>

View File

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

View File

@@ -3,6 +3,7 @@ import {
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import DOMPurify from "dompurify";
@@ -11,6 +12,7 @@ import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import OrderConfirmationModal from "../components/OrderConfirmationModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
@@ -112,13 +114,20 @@ export default function OrderDetail() {
show: boolean;
status: string | 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 [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = 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 () => {
try {
@@ -128,6 +137,7 @@ export default function OrderDetail() {
if (result.success) {
setOrder(result.data);
setNotes(result.data.notes || "");
initialNotesRef.current = result.data.notes || "";
} else {
alert.error(result.error || "Nepodařilo se načíst objednávku");
navigate("/orders");
@@ -144,6 +154,21 @@ export default function OrderDetail() {
fetchDetail();
}, [fetchDetail]);
const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current;
}, [notes]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => {
@@ -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 () => {
setSaving(true);
try {
@@ -233,6 +222,7 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} else {
alert.error(result.error || "Nepodařilo se uložit poznámky");
}
@@ -256,7 +246,8 @@ export default function OrderDetail() {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
@@ -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 () => {
setDeleting(true);
try {
@@ -361,102 +396,7 @@ export default function OrderDetail() {
</Link>
<div>
<h1 className="admin-page-title flex-row-gap">
{editingNumber ? (
<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>Objednávka {order.order_number}</span>
<span
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
>
@@ -506,6 +446,24 @@ export default function OrderDetail() {
</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") &&
order.valid_transitions?.filter((s) => s !== "stornovana").length! >
0 &&
@@ -747,18 +705,18 @@ export default function OrderDetail() {
)}
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<div className="admin-totals-summary">
<div className="admin-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, order.currency)}</span>
</div>
{Number(order.apply_vat) > 0 && (
<div className="offers-totals-row">
<div className="admin-totals-row">
<span>DPH ({order.vat_rate}%):</span>
<span>{formatCurrency(totals.vatAmount, order.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<div className="admin-totals-row admin-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, order.currency)}</span>
</div>
@@ -788,16 +746,16 @@ export default function OrderDetail() {
{order.scope_description}
</div>
)}
<div className="offers-scope-list">
<div className="admin-scope-list">
{order.sections.map((section, index) => (
<div
key={section.id || index}
className="offers-scope-section"
className="admin-scope-section"
style={{ cursor: "default" }}
>
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">
<div className="admin-scope-section-header">
<span className="admin-scope-number">{index + 1}.</span>
<span className="admin-scope-title">
{(order.language === "CZ"
? section.title_cz || section.title
: section.title || section.title_cz) ||
@@ -806,7 +764,7 @@ export default function OrderDetail() {
</div>
{section.content && (
<div
className="offers-scope-content rich-text-view"
className="admin-scope-content admin-rich-text-view"
style={{ padding: "1rem" }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content),
@@ -900,6 +858,26 @@ export default function OrderDetail() {
type="danger"
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>
);
}

View File

@@ -161,7 +161,6 @@ export default function ProjectCreate() {
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,
};
@@ -172,9 +171,8 @@ export default function ProjectCreate() {
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.project_id}`, {
state: { created: true },
});
alert.success("Projekt byl vytvořen");
navigate(`/projects/${data.data.id}`);
} else {
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}
@@ -265,9 +263,12 @@ export default function ProjectCreate() {
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm("project_number", e.target.value)}
readOnly
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
@@ -284,7 +285,7 @@ export default function ProjectCreate() {
<div className="admin-form-row">
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="offers-customer-selected">
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
<button
type="button"
@@ -308,7 +309,7 @@ export default function ProjectCreate() {
</div>
) : (
<div
className="offers-customer-select"
className="admin-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
@@ -323,16 +324,16 @@ export default function ProjectCreate() {
placeholder="Hledat zákazníka..."
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
<div className="admin-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
<div className="admin-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="offers-customer-dropdown-item"
className="admin-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>

View File

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

View File

@@ -21,8 +21,8 @@ const STATUS_CLASSES: Record<string, string> = {
unpaid: "admin-badge-invoice-overdue",
paid: "admin-badge-invoice-paid",
};
const CURRENCY_OPTIONS = ["CZK", "EUR", "USD", "GBP"];
const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21];
const DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"];
const DEFAULT_VAT_RATES = [0, 10, 12, 15, 21];
const MONTH_NAMES = [
"leden",
@@ -115,7 +115,7 @@ function formatCzkWithDetail(
return { value: "0 Kč", detail: null };
}
const hasForeign = amounts.some((a) => a.currency !== "CZK");
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
if (hasForeign && totalCzk != null) {
return {
value: formatCurrency(totalCzk, "CZK"),
detail: formatMultiCurrency(amounts),
@@ -124,13 +124,20 @@ function formatCzkWithDetail(
return { value: formatMultiCurrency(amounts), detail: null };
}
function emptyMeta(): UploadMeta {
interface CompanySettings {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
amount: "",
currency: "CZK",
vat_rate: "21",
currency: settings?.default_currency || "CZK",
vat_rate: String(settings?.default_vat_rate ?? 21),
issue_date: "",
due_date: "",
notes: "",
@@ -154,6 +161,7 @@ export default function ReceivedInvoices({
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slideKey, setSlideKey] = useState(0);
const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear);
@@ -168,6 +176,8 @@ export default function ReceivedInvoices({
const [saving, setSaving] = useState(false);
const [supplierNames, setSupplierNames] = useState<string[]>([]);
const [companySettings, setCompanySettings] =
useState<CompanySettings | null>(null);
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -177,17 +187,19 @@ export default function ReceivedInvoices({
useModalLock(uploadOpen || editOpen);
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current;
const curr = statsYear * 12 + statsMonth;
if (curr > prev) {
slideDirection.current = 1;
}
if (curr < prev) {
slideDirection.current = -1;
}
prevMonth.current = statsMonth;
prevYear.current = statsYear;
}, [statsMonth, statsYear]);
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
// Compute slide direction during render (not in effect) so it's
// available for the current frame instead of one render late.
const prevTotal = prevYear.current * 12 + prevMonth.current;
const currTotal = statsYear * 12 + statsMonth;
if (currTotal > prevTotal) slideDirection.current = 1;
else if (currTotal < prevTotal) slideDirection.current = -1;
prevMonth.current = statsMonth;
prevYear.current = statsYear;
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);
@@ -231,6 +243,22 @@ export default function ReceivedInvoices({
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions =
companySettings?.available_vat_rates || DEFAULT_VAT_RATES;
const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
@@ -292,7 +320,10 @@ export default function ReceivedInvoices({
return true;
});
setUploadFiles((prev) => [...prev, ...valid]);
setUploadMeta((prev) => [...prev, ...valid.map(() => emptyMeta())]);
setUploadMeta((prev) => [
...prev,
...valid.map(() => emptyMeta(companySettings)),
]);
e.target.value = "";
};
@@ -475,19 +506,23 @@ export default function ReceivedInvoices({
};
const openFile = async (inv: ReceivedInvoice) => {
const newWindow = window.open("", "_blank");
try {
const response = await apiFetch(
`${API_BASE}/received-invoices/${inv.id}/file`,
);
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se načíst soubor");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
}
};
@@ -518,7 +553,7 @@ export default function ReceivedInvoices({
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
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) => (
<div key={i} className="admin-stat-card">
<div
@@ -555,7 +590,7 @@ export default function ReceivedInvoices({
>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
className="admin-kpi-grid admin-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({
@@ -678,9 +713,9 @@ export default function ReceivedInvoices({
<p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission("invoices.create") && (
<p
className="text-md"
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Nahrajte faktury tlačítkem výše.
@@ -749,7 +784,8 @@ export default function ReceivedInvoices({
/>
</th>
<th
style={{ textAlign: "right", cursor: "pointer" }}
className="text-right"
style={{ cursor: "pointer" }}
onClick={() => handleSort("amount")}
>
Částka{" "}
@@ -928,9 +964,9 @@ export default function ReceivedInvoices({
Vybrat soubory
</button>
<span
className="text-sm"
style={{
marginLeft: "0.75rem",
fontSize: "0.8125rem",
color: "var(--text-tertiary)",
}}
>
@@ -1041,12 +1077,14 @@ export default function ReceivedInvoices({
<FormField label="Měna" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.currency || "CZK"}
value={
uploadMeta[idx]?.currency || defaultCurrency
}
onChange={(e) =>
updateMeta(idx, "currency", e.target.value)
}
>
{CURRENCY_OPTIONS.map((c) => (
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
@@ -1056,12 +1094,14 @@ export default function ReceivedInvoices({
<FormField label="DPH %" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.vat_rate || "21"}
value={
uploadMeta[idx]?.vat_rate || defaultVatRate
}
onChange={(e) =>
updateMeta(idx, "vat_rate", e.target.value)
}
>
{VAT_RATE_OPTIONS.map((r) => (
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
@@ -1071,8 +1111,8 @@ export default function ReceivedInvoices({
</div>
{uploadMeta[idx]?.amount && (
<div
className="text-xs"
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginTop: "-0.25rem",
marginBottom: "0.5rem",
@@ -1085,14 +1125,14 @@ export default function ReceivedInvoices({
uploadMeta[idx].amount || "0",
);
const r = parseFloat(
uploadMeta[idx].vat_rate || "21",
uploadMeta[idx].vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
uploadMeta[idx].currency || "CZK",
uploadMeta[idx].currency || defaultCurrency,
)}
</div>
)}
@@ -1255,7 +1295,7 @@ export default function ReceivedInvoices({
}
disabled={ro}
>
{CURRENCY_OPTIONS.map((c) => (
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
@@ -1275,7 +1315,7 @@ export default function ReceivedInvoices({
}
disabled={ro}
>
{VAT_RATE_OPTIONS.map((r) => (
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
@@ -1285,8 +1325,8 @@ export default function ReceivedInvoices({
</div>
{editInvoice.amount && (
<div
className="text-xs"
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.75rem",
}}
@@ -1296,14 +1336,14 @@ export default function ReceivedInvoices({
(() => {
const a = parseFloat(editInvoice.amount || "0");
const r = parseFloat(
editInvoice.vat_rate || "21",
editInvoice.vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
editInvoice.currency || "CZK",
editInvoice.currency || defaultCurrency,
)}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,7 @@ export default function TripsAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
@@ -124,22 +125,18 @@ export default function TripsAdmin() {
useEffect(() => {
const fetchLookups = async () => {
try {
const [vRes, uRes] = await Promise.all([
const [vRes, uRes, csRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`),
apiFetch(`${API_BASE}/trips/users`),
apiFetch(`${API_BASE}/company-settings`),
]);
const vJson = await vRes.json();
const uJson = await uRes.json();
const csJson = await csRes.json();
if (vJson.success) setVehicles(vJson.data);
if (csJson.success) setCompanyName(csJson.data.company_name || "");
if (uJson.success) {
setUsers(
uJson.data.map(
(u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
}),
),
);
setUsers(uJson.data);
}
} catch {
// silently fail, filters will just be empty
@@ -886,13 +883,13 @@ export default function TripsAdmin() {
<div className="print-header">
<div className="print-header-left">
<img
src="/images/logo-light.png"
alt="BOHA"
src="/api/admin/company-settings/logo?variant=light"
alt=""
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div>
<div className="company">{companyName}</div>
</div>
</div>
<div className="print-header-right">

View File

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

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

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

View File

@@ -12,6 +12,8 @@ export async function securityHeaders(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(self)",
);
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
if (config.isProduction) {
reply.header(
@@ -22,11 +24,14 @@ export async function securityHeaders(
"Content-Security-Policy",
[
"default-src 'self'",
"script-src 'self' https://unpkg.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com",
"script-src 'self'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org",
"connect-src 'self' https://nominatim.openstreetmap.org",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
].join("; "),
);
}

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ export default async function bankAccountsRoutes(
): Promise<void> {
fastify.get(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({
orderBy: { position: "asc" },
@@ -25,7 +25,7 @@ export default async function bankAccountsRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const parsed = parseBody(CreateBankAccountSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -59,7 +59,7 @@ export default async function bankAccountsRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -126,7 +126,7 @@ export default async function bankAccountsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -5,7 +5,13 @@ import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response";
import multipart from "@fastify/multipart";
import { parseBody } from "../../schemas/common";
import { UpdateCompanySettingsSchema } from "../../schemas/company-settings.schema";
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
import { invalidateSettingsCache } from "../../services/system-settings";
import os from "os";
import { config } from "../../config/env";
import { NasFileManager } from "../../services/nas-file-manager";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { nasOffersManager } from "../../services/nas-offers-manager";
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(
@@ -53,27 +59,48 @@ export default async function companySettingsRoutes(
): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
// GET /api/admin/company-settings/logo
fastify.get("/logo", { preHandler: requireAuth }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { logo_data: true },
});
if (!settings?.logo_data) return error(reply, "Logo nenalezeno", 404);
// GET /api/admin/company-settings/logo?variant=light|dark
fastify.get("/logo", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, string>;
const variant = query.variant === "dark" ? "dark" : "light";
const column = variant === "dark" ? "logo_data_dark" : "logo_data";
const settings = await prisma.company_settings.findFirst({
select: { [column]: true },
});
const buf = settings?.[column] as unknown as Buffer | null;
if (!buf) return error(reply, "Logo nenalezeno", 404);
// Detect image type from magic bytes
const buf = settings.logo_data;
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
)
mime = "image/webp";
return reply.type(mime).send(buf);
return reply
.type(mime)
.header("Cache-Control", "public, max-age=3600")
.send(buf);
});
// POST /api/admin/company-settings/logo
// POST /api/admin/company-settings/logo?variant=light|dark
fastify.post(
"/logo",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const variant = query.variant === "dark" ? "dark" : "light";
const column = variant === "dark" ? "logo_data_dark" : "logo_data";
const file = await request.file();
if (!file) return error(reply, "Nebyl nahrán žádný soubor", 400);
@@ -92,7 +119,7 @@ export default async function companySettingsRoutes(
await prisma.company_settings.update({
where: { id: existing.id },
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
data: { [column]: new Uint8Array(buffer), modified_at: new Date() },
});
await logAudit({
@@ -101,13 +128,14 @@ export default async function companySettingsRoutes(
action: "update",
entityType: "company_settings",
entityId: existing.id,
description: "Nahráno logo",
description: `Nahráno logo (${variant})`,
});
return success(reply, null, 200, "Logo nahráno");
},
);
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
// Use upsert to avoid race condition between findFirst and create
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
@@ -129,63 +157,250 @@ export default async function companySettingsRoutes(
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) {
settings = await prisma.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,
},
// Wrap create in a transaction to handle race condition:
// another request may have created settings between findFirst and create
settings = await prisma.$transaction(async (tx) => {
// Double-check inside transaction
const existing = await tx.company_settings.findFirst({
select: { id: true },
});
if (existing) {
return tx.company_settings.findUnique({
where: { id: existing.id },
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
break_threshold_hours: true,
break_duration_short: true,
break_duration_long: true,
clock_rounding_minutes: true,
invoice_alert_email: true,
leave_notify_email: true,
max_login_attempts: true,
lockout_minutes: true,
max_requests_per_minute: true,
available_vat_rates: true,
available_currencies: true,
smtp_from: true,
smtp_from_name: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
});
}
return tx.company_settings.create({
data: {
company_name: "",
quotation_prefix: "N",
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);
// Check if logo exists
const logoCheck = await prisma.company_settings.findFirst({
where: { id: settings.id },
select: { logo_data: true },
select: { logo_data: true, logo_data_dark: true },
});
const has_logo = !!logoCheck?.logo_data;
const has_logo_dark = !!logoCheck?.logo_data_dark;
const { custom_fields, supplier_field_order } = decodeCustomFields(
settings.custom_fields as string | null,
);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
try {
const raw = settings.available_vat_rates as string | null;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0)
available_vat_rates = parsed;
}
} catch {
/* ignore */
}
let available_currencies: string[] = ["CZK", "EUR", "USD", "GBP"];
try {
const raw = settings.available_currencies as string | null;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0)
available_currencies = parsed;
}
} catch {
/* ignore */
}
return success(reply, {
...settings,
custom_fields,
supplier_field_order,
available_vat_rates,
available_currencies,
has_logo,
has_logo_dark,
app_version: pkg.version,
});
});
// GET /api/admin/company-settings/system-info
fastify.get(
"/system-info",
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
const uptimeSec = process.uptime();
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
const uptimeStr =
days > 0
? `${days}d ${hours}h ${mins}m`
: hours > 0
? `${hours}h ${mins}m`
: `${mins}m`;
const mem = process.memoryUsage();
const totalMem = os.totalmem();
const freeMem = os.freemem();
// DB connection check
let dbStatus = "ok";
let migrationCount = 0;
try {
const result = await prisma.$queryRaw<[{ cnt: bigint }]>`
SELECT COUNT(*) as cnt FROM _prisma_migrations WHERE finished_at IS NOT NULL
`;
migrationCount = Number(result[0]?.cnt ?? 0);
} catch (err) {
dbStatus = "error";
request.log.error(err, "DB health check failed");
}
// NAS status
const projectNas = new NasFileManager();
return success(reply, {
app_version: pkg.version,
node_version: process.version,
platform: `${os.type()} ${os.release()}`,
uptime: uptimeStr,
environment: config.appEnv,
timezone:
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone,
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)} MB`,
heap_used: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`,
heap_total: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`,
system_total: `${Math.round(totalMem / 1024 / 1024)} MB`,
system_free: `${Math.round(freeMem / 1024 / 1024)} MB`,
},
database: {
status: dbStatus,
migrations_applied: migrationCount,
},
nas: {
projects: {
configured: projectNas.isConfigured(),
},
financials: {
configured: nasFinancialsManager.isConfigured(),
},
offers: {
configured: nasOffersManager.isConfigured(),
},
},
});
},
);
fastify.put(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -207,6 +422,13 @@ export default async function companySettingsRoutes(
"default_currency",
"order_type_code",
"invoice_type_code",
"invoice_alert_email",
"leave_notify_email",
"smtp_from",
"smtp_from_name",
"offer_number_pattern",
"order_number_pattern",
"invoice_number_pattern",
];
const bodyRec = body as Record<string, unknown>;
for (const f of strFields) {
@@ -216,6 +438,24 @@ export default async function companySettingsRoutes(
if (body.default_vat_rate !== undefined)
data.default_vat_rate = Number(body.default_vat_rate);
if (body.require_2fa !== undefined) data.require_2fa = !!body.require_2fa;
const numFields = [
"break_threshold_hours",
"break_duration_short",
"break_duration_long",
"clock_rounding_minutes",
"max_login_attempts",
"lockout_minutes",
"max_requests_per_minute",
] as const;
for (const f of numFields) {
if (bodyRec[f] !== undefined) data[f] = Number(bodyRec[f]);
}
if (body.available_vat_rates !== undefined)
data.available_vat_rates = JSON.stringify(body.available_vat_rates);
if (body.available_currencies !== undefined)
data.available_currencies = JSON.stringify(body.available_currencies);
if (
body.custom_fields !== undefined ||
body.supplier_field_order !== undefined
@@ -240,13 +480,15 @@ export default async function companySettingsRoutes(
: existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
data.sync_version = { increment: 1 };
await prisma.company_settings.update({
where: { id: existing.id },
data,
});
invalidateSettingsCache();
await logAudit({
request,
authData: request.authData,

View File

@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
export default async function customersRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
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,
fastify.get(
"/",
{ preHandler: requirePermission("customers.view") },
async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
);
return {
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
return reply.send({
success: true,
data: enriched,
pagination: buildPaginationMeta(total, page, limit),
});
});
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 {
...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 } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requirePermission("customers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -29,7 +29,7 @@ export default async function leaveRequestsRoutes(
const isAdmin = authData.permissions.includes("attendance.approve");
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);
if (query.status) where.status = String(query.status);
@@ -241,57 +241,83 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8;
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
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;
try {
await prisma.$transaction(async (tx) => {
// Check for duplicate attendance records inside the transaction
for (const ac of attendanceCreates) {
const duplicate = await tx.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
});
if (duplicate) {
throw new Error(
"Pro zvolené datumy již existují záznamy docházky",
);
}
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(),
},
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
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({
request,
@@ -331,6 +357,7 @@ export default async function leaveRequestsRoutes(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.leave_requests.findUnique({
@@ -342,6 +369,10 @@ export default async function leaveRequestsRoutes(
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
}
if (existing.user_id !== authData.userId) {
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
}
await prisma.leave_requests.update({
where: { id },
data: { status: "cancelled" },

View File

@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
@@ -73,6 +79,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {
@@ -102,7 +109,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
try {
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
@@ -381,19 +394,8 @@ export default async function offersPdfRoutes(
img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes ---- */
.ql-font-arial { font-family: Arial, sans-serif; }
.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 font classes v PDF vynuceno Tahoma ---- */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
@@ -517,7 +519,7 @@ ${indentCSS}
}
table.items tbody td.desc {
font-size: 10pt;
font-weight: 500;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.total-cell {
@@ -606,6 +608,15 @@ ${indentCSS}
word-break: normal;
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 ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
@@ -763,28 +774,24 @@ ${indentCSS}
</body>
</html>`;
const saveMode = query.save === "1";
// Save PDF to NAS
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at
? new Date(quotation.created_at)
: new Date();
const saveMode = query.save === "1";
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
pdfBuffer,
);
})
.catch((err) => {
request.log.error(err, "Failed to save offer PDF to NAS");
});
if (saveMode) {
await pdfPromise;
return reply.send({ success: true, message: "PDF uloženo" });
}
const pdfBuffer = await htmlToPdf(html);
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
}
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 { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { config } from "../../config/env";
import {
CreateOrderFromQuotationSchema,
CreateOrderSchema,
@@ -25,7 +26,9 @@ import multipart from "@fastify/multipart";
export default async function ordersRoutes(
fastify: FastifyInstance,
): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await fastify.register(multipart, {
limits: { fileSize: config.nas.maxUploadSize },
});
// GET /api/admin/orders/next-number
fastify.get(
@@ -54,15 +57,11 @@ export default async function ordersRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, result.page, result.limit),
);
},
);
@@ -88,12 +87,10 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply
.type("application/pdf")
.header(
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
.send(attachment.data);
},
);
@@ -209,6 +206,7 @@ export default async function ordersRoutes(
const body = manualParsed.data;
const result = await createOrder(body);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({
request,

View File

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

View File

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

View File

@@ -69,6 +69,9 @@ export default async function projectsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const project = await createProject(parsed.data);
if ("error" in project) {
return error(reply, project.error, (project as any).status ?? 400);
}
await logAudit({
request,
@@ -91,8 +94,11 @@ export default async function projectsRoutes(
const parsed = parseBody(UpdateProjectSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const existing = await updateProject(id, parsed.data);
if (!existing) return error(reply, "Projekt nenalezen", 404);
const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) {
return error(reply, result.error, (result as any).status ?? 400);
}
await logAudit({
request,
@@ -100,7 +106,7 @@ export default async function projectsRoutes(
action: "update",
entityType: "project",
entityId: id,
description: `Upraven projekt ${existing.name}`,
description: `Upraven projekt ${result.name}`,
});
return success(reply, { id }, 200, "Projekt byl uložen");
},
@@ -123,6 +129,9 @@ export default async function projectsRoutes(
lastName: authData.lastName,
content: parsed.data.content ?? undefined,
});
if (note && "error" in note) {
return error(reply, note.error, (note as any).status ?? 400);
}
return success(reply, { note }, 201, "Poznámka byla přidána");
},

View File

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

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