Compare commits

..

38 Commits

Author SHA1 Message Date
BOHA
5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00
BOHA
dac45baaa8 test(warehouse): add integration tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:41:15 +02:00
BOHA
f389c4b3cf feat(warehouse): add reservations, inventory, reports, and dashboard pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:36:31 +02:00
BOHA
fdc7037e60 feat(warehouse): add issue pages 2026-05-29 14:32:11 +02:00
BOHA
b02f6afe93 feat(warehouse): add receipt pages 2026-05-29 14:29:44 +02:00
BOHA
1b2ab7b4eb feat(warehouse): add items list and detail pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:27:15 +02:00
BOHA
29975ecad9 feat(warehouse): add categories, suppliers, locations pages 2026-05-29 14:23:31 +02:00
BOHA
e846f15126 feat(warehouse): add sidebar entry and routes 2026-05-29 14:21:35 +02:00
BOHA
86bebf9776 feat(warehouse): add shared warehouse components
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:20:42 +02:00
BOHA
7b7c6bde68 feat(warehouse): add TanStack Query options and types 2026-05-29 14:19:38 +02:00
BOHA
935fb235a6 feat(warehouse): register warehouse routes in server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:18:16 +02:00
BOHA
d2d1cdbb92 feat(warehouse): add receipt, issue, reservation, inventory, and report routes
- Receipts: CRUD + confirm/cancel + multipart attachment upload/delete
- Issues: CRUD + confirm/cancel with auto-FIFO batch selection
- Reservations: list + create + cancel
- Inventory sessions: CRUD + confirm with auto system_qty calculation
- Reports: stock-status, project-consumption, movement-log, below-minimum
- Add confirm/cancel/upload audit actions and warehouse_receipt_attachment entity type

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:17:33 +02:00
BOHA
5f08b0e086 feat(warehouse): add master data API routes
Categories, suppliers, locations, and items CRUD endpoints with
pagination, search, computed stock fields, audit logging, and
conflict validation (409 for references, unit change with batches).
2026-05-29 14:12:53 +02:00
BOHA
637499a8ec feat(warehouse): add warehouse service with FIFO and business logic
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:11:22 +02:00
BOHA
f413bae348 feat(warehouse): add Zod validation schemas 2026-05-29 14:09:39 +02:00
BOHA
2b1de583fd feat(warehouse): add entity types, permissions, and number sequences 2026-05-29 14:08:35 +02:00
BOHA
5500cdb118 feat(warehouse): add Prisma schema for warehouse module
Add 13 warehouse models (sklad_categories, sklad_suppliers, sklad_locations,
sklad_items, sklad_batches, sklad_item_locations, sklad_receipts,
sklad_receipt_lines, sklad_receipt_attachments, sklad_issues,
sklad_issue_lines, sklad_reservations, sklad_inventory_sessions,
sklad_inventory_lines) and 4 enums (sklad_receipt_status, sklad_issue_status,
sklad_reservation_status, sklad_inventory_status).

Add reverse relations to projects and users models with named relations
to avoid conflicts with existing relations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:08:00 +02:00
BOHA
2254a13ad0 v1.7.0: fix offer created_at override, invoice PDF translations, remove has_order filter
- Add created_at to offer create/update schemas so user-selected date is stored instead of always defaulting to now()
- Translate payment method in invoice PDF based on language (Příkazem → Bank transfer, etc.)
- Add order_date and cnb_rate translation keys, replace hardcoded inline strings
- Remove duplicate has_order filter from offers backend route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:35:36 +02:00
BOHA
1f5885de84 v1.6.9: bump version
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:37 +02:00
BOHA
705f58e3d1 v1.6.9: cleanup offer dead columns, add item template picker to offer form
- Remove dead DB columns: uuid, sync_version from quotations/quotation_items/scope_sections; is_deleted from quotation_items/scope_sections; content_editor_height from scope_sections
- Remove unused scope_title/scope_description from offer form state
- Remove dead CSS: offers-template-menu, offers-draft-indicator, exchange-rate
- Remove dead scope_title translation key from offers PDF
- Fix duplicate Customer interface in OfferDetail.tsx (use shared type)
- Add missing title field to ScopeTemplate interface
- Add item template picker dropdown next to "Přidat položku" button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:22 +02:00
BOHA
0330453ad6 v1.6.8: remove offer exchange rate, add invoice item description, offer filters, table animations
- Remove exchange_rate and exchange_rate_date from quotations (schema, service, PDF, form)
- Add item_description field to invoice_items (schema, migration, service, form, PDF)
- Add offer status/customer/order filters with tab-based UI
- Clean up offer statuses to active/ordered/invalidated
- Allow invalidate action for non-ordered offers (not just expired)
- Add fade animations on offers and invoices table data changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:51 +02:00
BOHA
66b98e2765 v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles
- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:03:31 +02:00
BOHA
55648c9a30 v1.6.6: fix permissions order in role edit modal
- Add secondary sort by id in permissions API to ensure deterministic within-module order

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:17:30 +02:00
BOHA
cd6d3daf43 v1.6.5: fix attendance unique constraint, schema sync, seed script
- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:21:34 +02:00
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
BOHA
9abec36f07 v1.5.7: fix Settings system tab crash and OffersTemplates tab gap
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:29:10 +02:00
BOHA
ecd8e3679f fix: replace stray role reference in system settings tab with inline placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:04:22 +02:00
BOHA
ba95723b61 v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones
- Remove skeleton.css and @keyframes shimmer from base.css
- Add <Skeleton> wrappers with fixtures to all 25+ page components
- Generate 20 bone captures via boneyard CLI (CDP auth-gated capture)
- Refactor data fetching from useEffect+useState to TanStack Query
- Extract query hooks into src/admin/lib/queries/ and apiAdapter
- Add usePaginatedQuery hook replacing useApiCall/useListData
- Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs
- Fix customer_id mandatory validation on offer creation
- Fix leave-requests comma-separated status filter (Prisma enum in: [])
- Add cross-entity cache invalidation for orders/offers/invoices/projects
- Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.)
- Add boneyard.config.json with routes and breakpoints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:35:43 +02:00
BOHA
12289bdce3 fix: only show session-expired alert when user had a valid session
Added hadValidSessionRef to track whether the user was ever
authenticated during this page load. setSessionExpired() in
silentRefresh now only fires when the ref is true, preventing
the alert on direct visits by unauthenticated users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:16:26 +02:00
BOHA
d1c5234a03 fix: allow logo endpoint without auth for <img> tag loading
Logo images are loaded via <img src> which doesn't carry auth cookies
reliably during login transitions. Changed from requireAuth to
optionalAuth — logos are not sensitive data.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:36:08 +02:00
170 changed files with 25722 additions and 11694 deletions

View File

@@ -1,43 +0,0 @@
---
name: compare-php
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
---
# Compare PHP vs TS Implementation
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
## Usage
The user will specify what to compare. Examples:
- `/compare-php attendance print`
- `/compare-php invoice PDF generation`
- `/compare-php offer numbering logic`
## Process
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
3. Compare:
- API endpoints and request/response shape
- Business logic and calculations
- Database queries
- Frontend behavior
4. Report differences found — what's missing, what's different, what's extra
## Key directories
**PHP project:**
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
- API routes: `D:\cortex\boha-app\api\admin\`
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
- Includes/utils: `D:\cortex\boha-app\api\includes\`
**TS project:**
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
- Services: `D:\cortex\boha-app-ts\src\services\`
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`

108
CLAUDE.md
View File

@@ -75,14 +75,20 @@ 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
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
```
**Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
---
## Environment Variables
@@ -248,6 +254,24 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
### Query Invalidation Convention
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
- `["users"]` over `["users", "list"]`
- `["trips"]` over `["trips", "vehicles"]`
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
---
## Database Conventions
@@ -260,6 +284,69 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
```
### Deploying migrations to production
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
---
## 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.
@@ -276,7 +363,7 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
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.
8. **Prisma client regeneration** — After any schema change and migration, 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.
@@ -293,10 +380,11 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
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`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
Do not push directly to production or restart services without confirmation.

37
boneyard.config.json Normal file
View File

@@ -0,0 +1,37 @@
{
"breakpoints": [375, 768, 1280],
"out": "./src/admin/bones",
"color": "#e0e0e0",
"animate": "shimmer",
"shimmerColor": "#f0f0f0",
"speed": "1.2s",
"shimmerAngle": 110,
"wait": 3000,
"routes": [
"/",
"/users",
"/attendance",
"/attendance/history",
"/attendance/admin",
"/attendance/balances",
"/attendance/requests",
"/attendance/approval",
"/attendance/create",
"/trips",
"/trips/history",
"/trips/admin",
"/vehicles",
"/offers",
"/offers/new",
"/offers/customers",
"/offers/templates",
"/orders",
"/orders/1",
"/projects",
"/projects/80",
"/invoices",
"/invoices/new",
"/settings",
"/audit-log"
]
}

412
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "1.5.3",
"version": "1.6.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "1.5.3",
"version": "1.6.7",
"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",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
@@ -350,21 +351,21 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -373,9 +374,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1099,9 +1100,9 @@
}
},
"node_modules/@fastify/static": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"funding": [
{
"type": "github",
@@ -1192,20 +1193,22 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/hashes": {
@@ -1220,20 +1223,10 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1292,34 +1285,34 @@
}
},
"node_modules/@prisma/config": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
"integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.18.4",
"effect": "3.21.0",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
"integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
"integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2",
"@prisma/debug": "6.19.3",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/fetch-engine": "6.19.2",
"@prisma/get-platform": "6.19.2"
"@prisma/fetch-engine": "6.19.3",
"@prisma/get-platform": "6.19.3"
}
},
"node_modules/@prisma/engines-version": {
@@ -1329,23 +1322,23 @@
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
"integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2",
"@prisma/debug": "6.19.3",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/get-platform": "6.19.2"
"@prisma/get-platform": "6.19.3"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
"integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2"
"@prisma/debug": "6.19.3"
}
},
"node_modules/@puppeteer/browsers": {
@@ -1379,9 +1372,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -1396,9 +1389,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -1413,9 +1406,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -1430,9 +1423,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -1447,9 +1440,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -1464,9 +1457,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -1481,9 +1474,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -1498,9 +1491,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -1515,9 +1508,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -1532,9 +1525,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -1549,9 +1542,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -1566,9 +1559,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -1583,9 +1576,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -1593,16 +1586,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -1617,9 +1612,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -1646,6 +1641,32 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.100.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.5.tgz",
"integrity": "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.5.tgz",
"integrity": "sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -1659,9 +1680,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2306,9 +2327,9 @@
"license": "MIT"
},
"node_modules/basic-ftp": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -2832,9 +2853,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/degenerator": {
@@ -2919,9 +2940,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -2964,9 +2985,9 @@
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -3358,9 +3379,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -3374,9 +3395,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
"funding": [
{
"type": "github",
@@ -3885,9 +3906,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4411,9 +4432,9 @@
}
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
@@ -4615,9 +4636,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -4658,23 +4679,23 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
"license": "MIT",
"dependencies": {
"citty": "^0.2.0",
"citty": "^0.2.2",
"pathe": "^2.0.3",
"tinyexec": "^1.0.2"
"tinyexec": "^1.1.1"
},
"bin": {
"nypm": "dist/cli.mjs"
@@ -4684,9 +4705,9 @@
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT"
},
"node_modules/object-inspect": {
@@ -4978,13 +4999,13 @@
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
@@ -4998,9 +5019,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -5027,14 +5048,14 @@
}
},
"node_modules/prisma": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.19.2",
"@prisma/engines": "6.19.2"
"@prisma/config": "6.19.3",
"@prisma/engines": "6.19.3"
},
"bin": {
"prisma": "build/index.js"
@@ -5553,14 +5574,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -5569,27 +5590,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -6147,23 +6168,23 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -6331,18 +6352,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"tinyglobby": "^0.2.15"
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -6358,8 +6378,8 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"esbuild": "^0.27.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.5.4",
"version": "1.8.0",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -17,7 +17,11 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"keywords": [],
"author": "",
@@ -34,6 +38,7 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",

View File

@@ -1,2 +0,0 @@
-- Add billing_text column to invoices table
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;

View File

@@ -1,3 +0,0 @@
-- Add lock fields to quotations table for pessimistic locking
ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`;
ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`;

View File

@@ -1,9 +0,0 @@
CREATE TABLE `invoice_alert_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INT NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1 +0,0 @@
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;

View File

@@ -1,2 +0,0 @@
-- Add file_path column for NAS storage of received invoice files
ALTER TABLE `received_invoices` ADD COLUMN `file_path` VARCHAR(500) NULL AFTER `file_data`;

View File

@@ -1,4 +0,0 @@
-- Remove file_data (BLOB) and file_path columns — files are now stored on NAS
-- Path is derived from year, month, and file_name
ALTER TABLE `received_invoices` DROP COLUMN `file_data`;
ALTER TABLE `received_invoices` DROP COLUMN `file_path`;

View File

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

View File

@@ -1,4 +0,0 @@
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

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

View File

@@ -1,13 +0,0 @@
-- 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

@@ -1,18 +0,0 @@
-- 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

@@ -1,2 +0,0 @@
-- 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

@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
`vat_id` VARCHAR(50) NULL,
`custom_fields` LONGTEXT NULL,
`logo_data` LONGBLOB NULL,
`logo_data_dark` MEDIUMBLOB NULL,
`quotation_prefix` VARCHAR(20) NULL,
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
`order_type_code` VARCHAR(10) NULL,
`invoice_type_code` VARCHAR(10) NULL,
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
`break_threshold_hours` DECIMAL(4, 2) NULL DEFAULT 6.00,
`break_duration_short` INTEGER NULL DEFAULT 15,
`break_duration_long` INTEGER NULL DEFAULT 30,
`clock_rounding_minutes` INTEGER NULL DEFAULT 15,
`invoice_alert_email` VARCHAR(255) NULL,
`leave_notify_email` VARCHAR(255) NULL,
`max_login_attempts` INTEGER NULL DEFAULT 5,
`lockout_minutes` INTEGER NULL DEFAULT 15,
`max_requests_per_minute` INTEGER NULL DEFAULT 300,
`available_vat_rates` LONGTEXT NULL,
`available_currencies` LONGTEXT NULL,
`smtp_from` VARCHAR(255) NULL,
`smtp_from_name` VARCHAR(255) NULL,
`offer_number_pattern` VARCHAR(100) NULL,
`order_number_pattern` VARCHAR(100) NULL,
`invoice_number_pattern` VARCHAR(100) NULL,
UNIQUE INDEX `company_settings_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
`tax_date` DATE NULL,
`paid_date` DATE NULL,
`issued_by` VARCHAR(255) NULL,
`billing_text` VARCHAR(500) NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_invoices_due_date`(`due_date`),
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
INDEX `idx_invoices_status_due`(`status`, `due_date`),
INDEX `order_id`(`order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
`year` INTEGER NULL,
`last_number` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
INDEX `customer_id`(`customer_id`),
INDEX `quotation_id`(`quotation_id`),
PRIMARY KEY (`id`)
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `projects_project_number_key`(`project_number`),
INDEX `customer_id`(`customer_id`),
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
INDEX `order_id`(`order_id`),
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`scope_title` VARCHAR(500) NULL,
`scope_description` TEXT NULL,
`locked_by` INTEGER NULL,
`locked_at` DATETIME(0) NULL,
`uuid` VARCHAR(36) NULL,
`modified_at` DATETIME(0) NULL,
`sync_version` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_quotations_number`(`quotation_number`),
PRIMARY KEY (`id`)
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
`due_date` DATE NULL,
`paid_date` DATE NULL,
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
`file_data` MEDIUMBLOB NULL,
`file_name` VARCHAR(255) NULL,
`file_mime` VARCHAR(100) NULL,
`file_size` INTEGER UNSIGNED NULL,
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
`totp_secret` VARCHAR(255) NULL,
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
`totp_backup_codes` TEXT NULL,
`totp_last_used_counter` INTEGER NULL,
UNIQUE INDEX `username`(`username`),
UNIQUE INDEX `email`(`email`),
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `invoice_alert_log` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INTEGER NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `invoice_type_invoice_id_alert_type`(`invoice_type`, `invoice_id`, `alert_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -674,4 +718,3 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
-- AddForeignKey
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;

View File

@@ -0,0 +1,4 @@
-- Insert the settings.system permission (also available via seed)
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
ON DUPLICATE KEY UPDATE `name` = `name`;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -0,0 +1,14 @@
-- AlterTable: quotation_items
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
-- AlterTable: quotations
ALTER TABLE `quotations` DROP COLUMN `uuid`;
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
-- AlterTable: scope_sections
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;

View File

@@ -0,0 +1,277 @@
-- CreateTable
CREATE TABLE `sklad_categories` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_suppliers` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`ico` VARCHAR(20) NULL,
`dic` VARCHAR(20) NULL,
`contact_person` VARCHAR(255) NULL,
`email` VARCHAR(255) NULL,
`phone` VARCHAR(50) NULL,
`address` TEXT NULL,
`notes` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(20) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_locations_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_number` VARCHAR(50) NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`category_id` INTEGER NULL,
`unit` VARCHAR(20) NOT NULL,
`min_quantity` DECIMAL(12, 3) NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_items_item_number_key`(`item_number`),
INDEX `sklad_items_category_id`(`category_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_batches` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`receipt_line_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`original_qty` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`received_at` DATETIME(0) NULL,
`is_consumed` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `sklad_batches_receipt_line_id_key`(`receipt_line_id`),
INDEX `sklad_batches_fifo`(`item_id`, `is_consumed`, `received_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_item_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL DEFAULT 0,
UNIQUE INDEX `sklad_item_locations_unique`(`item_id`, `location_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipts` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_number` VARCHAR(50) NULL,
`supplier_id` INTEGER NULL,
`delivery_note_number` VARCHAR(100) NULL,
`delivery_note_date` DATETIME(0) NULL,
`received_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`location_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_receipt_lines_receipt_id`(`receipt_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_attachments` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_mime` VARCHAR(100) NOT NULL,
`file_size` INTEGER NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issues` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_number` VARCHAR(50) NULL,
`project_id` INTEGER NOT NULL,
`issued_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issue_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`batch_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`location_id` INTEGER NULL,
`reservation_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_issue_lines_issue_id`(`issue_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_reservations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`project_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`remaining_qty` DECIMAL(12, 3) NOT NULL,
`reserved_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('ACTIVE', 'FULFILLED', 'CANCELLED') NOT NULL DEFAULT 'ACTIVE',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
INDEX `sklad_reservations_item_status`(`item_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_sessions` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_number` VARCHAR(50) NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NULL,
`system_qty` DECIMAL(12, 3) NOT NULL,
`actual_qty` DECIMAL(12, 3) NOT NULL,
`difference` DECIMAL(12, 3) NOT NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_inventory_lines_session_id`(`session_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `sklad_items` ADD CONSTRAINT `sklad_items_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `sklad_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_receipt_line_id_fkey` FOREIGN KEY (`receipt_line_id`) REFERENCES `sklad_receipt_lines`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_supplier_id_fkey` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_received_by_fkey` FOREIGN KEY (`received_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_batch_id_fkey` FOREIGN KEY (`batch_id`) REFERENCES `sklad_batches`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_reservation_id_fkey` FOREIGN KEY (`reservation_id`) REFERENCES `sklad_reservations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_reserved_by_fkey` FOREIGN KEY (`reserved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `warehouse_inventory_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_inventory_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_issue_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_issue_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_receipt_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_receipt_prefix` VARCHAR(20) NULL;
-- Set backward-compatible defaults for existing installations
UPDATE `company_settings`
SET
`warehouse_receipt_prefix` = 'PRI',
`warehouse_receipt_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_issue_prefix` = 'VYD',
`warehouse_issue_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_inventory_prefix` = 'INV',
`warehouse_inventory_number_pattern` = '{PREFIX}-{YYYY}-{NNN}';

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

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[]
@@unique([user_id, shift_date], map: "idx_attendance_user_date")
@@index([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")
}
@@ -101,7 +101,7 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes?
logo_data_dark Bytes? @db.MediumBlob
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)
@@ -112,7 +112,7 @@ model company_settings {
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
@@ -128,6 +128,12 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
}
model customers {
@@ -153,8 +159,9 @@ model customers {
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -356,6 +363,8 @@ model projects {
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
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")
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
@@ -377,10 +386,7 @@ model quotation_items {
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -389,7 +395,7 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
@@ -397,17 +403,13 @@ model quotations {
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
@@ -437,7 +439,7 @@ model received_invoices {
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int?
uploaded_by Int? @db.UnsignedInt
created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0)
@@ -449,7 +451,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
user_id Int @db.UnsignedInt
token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
@@ -492,11 +494,7 @@ model scope_sections {
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
content_editor_height Int?
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -590,6 +588,9 @@ model users {
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[]
trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active")
@@ -632,7 +633,7 @@ model invoice_alert_log {
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
}
enum received_invoices_status {
@@ -647,3 +648,272 @@ enum attendance_leave_type {
holiday
unpaid
}
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
reservations sklad_reservations[]
inventory_lines sklad_inventory_lines[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
items sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}

469
prisma/seed.ts Normal file
View File

@@ -0,0 +1,469 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const PERMISSIONS: {
name: string;
display_name: string;
module: string;
description: string;
}[] = [
// Docházka
{
name: "attendance.record",
display_name: "Záznam docházky",
module: "attendance",
description: "Zapisovat příchody/odchody",
},
{
name: "attendance.history",
display_name: "Historie docházky",
module: "attendance",
description: "Prohlížet vlastní historii docházky",
},
{
name: "attendance.approve",
display_name: "Schvalování docházky",
module: "attendance",
description: "Schvalovat žádosti o dovolenou/nemoc",
},
{
name: "attendance.manage",
display_name: "Správa docházky",
module: "attendance",
description: "Spravovat všechny záznamy, vytvářet/editovat/mazat",
},
{
name: "attendance.balances",
display_name: "Správa bilancí",
module: "attendance",
description: "Spravovat zůstatky dovolené a sick days",
},
// Kniha jízd
{
name: "trips.record",
display_name: "Záznam jízd",
module: "trips",
description: "Zapisovat jízdy",
},
{
name: "trips.history",
display_name: "Historie jízd",
module: "trips",
description: "Prohlížet vlastní historii jízd",
},
{
name: "trips.manage",
display_name: "Správa jízd",
module: "trips",
description: "Spravovat všechny jízdy",
},
// Vozidla
{
name: "vehicles.manage",
display_name: "Správa vozidel",
module: "vehicles",
description: "Spravovat vozový park",
},
// Nabídky
{
name: "offers.view",
display_name: "Zobrazit nabídky",
module: "offers",
description: "Prohlížet seznam nabídek",
},
{
name: "offers.create",
display_name: "Vytvořit nabídku",
module: "offers",
description: "Vytvářet nové nabídky",
},
{
name: "offers.edit",
display_name: "Upravit nabídku",
module: "offers",
description: "Upravovat existující nabídky",
},
{
name: "offers.delete",
display_name: "Smazat nabídku",
module: "offers",
description: "Mazat nabídky",
},
{
name: "offers.export",
display_name: "Exportovat nabídku",
module: "offers",
description: "Exportovat nabídky do PDF",
},
// Objednávky
{
name: "orders.view",
display_name: "Zobrazit objednávky",
module: "orders",
description: "Prohlížet seznam objednávek",
},
{
name: "orders.create",
display_name: "Vytvořit objednávku",
module: "orders",
description: "Vytvářet nové objednávky",
},
{
name: "orders.edit",
display_name: "Upravit objednávku",
module: "orders",
description: "Upravovat existující objednávky",
},
{
name: "orders.delete",
display_name: "Smazat objednávku",
module: "orders",
description: "Mazat objednávky",
},
{
name: "orders.export",
display_name: "Exportovat objednávku",
module: "orders",
description: "Exportovat objednávky do PDF",
},
// Faktury
{
name: "invoices.view",
display_name: "Zobrazit faktury",
module: "invoices",
description: "Prohlížet seznam faktur",
},
{
name: "invoices.create",
display_name: "Vytvořit fakturu",
module: "invoices",
description: "Vytvářet nové faktury",
},
{
name: "invoices.edit",
display_name: "Upravit fakturu",
module: "invoices",
description: "Upravovat existující faktury",
},
{
name: "invoices.delete",
display_name: "Smazat fakturu",
module: "invoices",
description: "Mazat faktury",
},
{
name: "invoices.export",
display_name: "Exportovat fakturu",
module: "invoices",
description: "Exportovat faktury do PDF",
},
// Projekty
{
name: "projects.view",
display_name: "Zobrazit projekty",
module: "projects",
description: "Prohlížet seznam projektů",
},
{
name: "projects.edit",
display_name: "Upravit projekt",
module: "projects",
description: "Upravovat existující projekty",
},
{
name: "projects.delete",
display_name: "Smazat projekt",
module: "projects",
description: "Mazat projekty",
},
{
name: "projects.files",
display_name: "Soubory projektu",
module: "projects",
description: "Spravovat soubory a složky projektu",
},
// Zákazníci
{
name: "customers.view",
display_name: "Zobrazit zákazníky",
module: "customers",
description: "Prohlížet seznam zákazníků",
},
{
name: "customers.create",
display_name: "Vytvořit zákazníka",
module: "customers",
description: "Vytvářet nové zákazníky",
},
{
name: "customers.edit",
display_name: "Upravit zákazníka",
module: "customers",
description: "Upravovat existující zákazníky",
},
{
name: "customers.delete",
display_name: "Smazat zákazníka",
module: "customers",
description: "Mazat zákazníky",
},
// Uživatelé
{
name: "users.view",
display_name: "Zobrazit uživatele",
module: "users",
description: "Prohlížet seznam uživatelů",
},
{
name: "users.create",
display_name: "Vytvořit uživatele",
module: "users",
description: "Vytvářet nové uživatele",
},
{
name: "users.edit",
display_name: "Upravit uživatele",
module: "users",
description: "Upravovat existující uživatele",
},
{
name: "users.delete",
display_name: "Smazat uživatele",
module: "users",
description: "Mazat uživatele",
},
// Nastavení
{
name: "settings.company",
display_name: "Nastavení společnosti",
module: "settings",
description: "Upravovat údaje o společnosti, logo",
},
{
name: "settings.system",
display_name: "Systémová nastavení",
module: "settings",
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
},
{
name: "settings.banking",
display_name: "Bankovní účty",
module: "settings",
description: "Spravovat bankovní účty společnosti",
},
{
name: "settings.roles",
display_name: "Role a oprávnění",
module: "settings",
description: "Spravovat role a přiřazovat oprávnění",
},
{
name: "settings.templates",
display_name: "Šablony",
module: "settings",
description: "Spravovat šablony nabídek a položek",
},
{
name: "settings.audit",
display_name: "Audit log",
module: "settings",
description: "Prohlížet auditní logy",
},
// Sklad
{
name: "warehouse.view",
display_name: "Zobrazit sklad",
module: "warehouse",
description: "Prohlížet stav skladu, položky, reporty a historii pohybů",
},
{
name: "warehouse.operate",
display_name: "Příjem a výdej",
module: "warehouse",
description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace",
},
{
name: "warehouse.manage",
display_name: "Správa skladu",
module: "warehouse",
description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie",
},
{
name: "warehouse.inventory",
display_name: "Inventura",
module: "warehouse",
description: "Vytvářet a potvrzovat inventurní sčítkání",
},
];
async function main() {
console.log("Seeding permissions...");
// 1. Clear existing permissions and re-insert current set
await prisma.role_permissions.deleteMany({});
await prisma.permissions.deleteMany({});
console.log(" Cleared old permissions");
for (const perm of PERMISSIONS) {
await prisma.permissions.create({ data: perm });
}
console.log(` Inserted ${PERMISSIONS.length} permissions`);
// 2. Ensure admin role exists
let adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole) {
adminRole = await prisma.roles.create({
data: {
name: "admin",
display_name: "Administrátor",
description: "Hlavní administrátor s plným přístupem",
},
});
console.log(" Created admin role");
} else {
console.log(" Admin role already exists (id=" + adminRole.id + ")");
}
// 3. Assign all permissions to admin role
const allPerms = await prisma.permissions.findMany();
const existingAssignments = await prisma.role_permissions.findMany({
where: { role_id: adminRole.id },
});
// Delete stale assignments (permissions no longer in the list)
const validIds = new Set(allPerms.map((p) => p.id));
const staleIds = existingAssignments.filter(
(a) => !validIds.has(a.permission_id),
);
if (staleIds.length > 0) {
for (const s of staleIds) {
await prisma.role_permissions.delete({
where: {
role_id_permission_id: {
role_id: adminRole.id,
permission_id: s.permission_id,
},
},
});
}
console.log(` Removed ${staleIds.length} stale role_permissions`);
}
// Add missing assignments
const existingIds = new Set(existingAssignments.map((a) => a.permission_id));
let added = 0;
for (const perm of allPerms) {
if (!existingIds.has(perm.id)) {
await prisma.role_permissions.create({
data: { role_id: adminRole.id, permission_id: perm.id },
});
added++;
}
}
if (added > 0) console.log(` Added ${added} role_permission assignments`);
console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",
password_hash: hash,
first_name: "Admin",
last_name: "BOHA",
role_id: adminRole.id,
is_active: true,
},
});
console.log(" Created admin user (admin / admin)");
} else if (adminUser.role_id !== adminRole.id) {
await prisma.users.update({
where: { id: adminUser.id },
data: { role_id: adminRole.id },
});
console.log(" Updated admin user role_id to admin role");
} else {
console.log(" Admin user already exists with correct role");
}
// 5. Create default company_settings if not exists
const existingSettings = await prisma.company_settings.findFirst();
if (!existingSettings) {
await prisma.company_settings.create({
data: {
company_name: "BOHA s.r.o.",
default_currency: "CZK",
default_vat_rate: 21.0,
available_vat_rates: JSON.stringify([0, 10, 15, 21]),
available_currencies: JSON.stringify(["CZK", "EUR"]),
offer_number_pattern: "NAB-{YYYY}-{NNN}",
order_number_pattern: "OBJ-{YYYY}-{NNN}",
invoice_number_pattern: "FV-{YYYY}-{NNN}",
break_threshold_hours: 6.0,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
require_2fa: false,
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
},
});
console.log(" Created default company_settings");
} else {
console.log(" company_settings already exists");
}
// 6. Initialize number_sequences for current year
const year = new Date().getFullYear();
const types = [
"quotation",
"order",
"invoice",
"warehouse_receipt",
"warehouse_issue",
"warehouse_inventory",
];
for (const type of types) {
const existing = await prisma.number_sequences.findFirst({
where: { type, year },
});
if (!existing) {
await prisma.number_sequences.create({
data: { type, year, last_number: 0 },
});
console.log(` Created number_sequence: ${type}/${year}`);
}
}
console.log(" number_sequences ready");
console.log(
"\nSeed complete — " +
PERMISSIONS.length +
" permissions, 1 admin role, company_settings + number_sequences ready.",
);
}
main()
.catch((e) => {
console.error("Seed failed:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer";
@@ -14,7 +16,6 @@ import "./buttons.css";
import "./layout.css";
import "./components.css";
import "./tables.css";
import "./skeleton.css";
import "./datepicker.css";
import "./filemanager.css";
import "./pagination.css";
@@ -25,6 +26,7 @@ import "./attendance.css";
import "./settings.css";
import "./offers.css";
import "./invoices.css";
import "./warehouse.css";
const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance"));
@@ -46,76 +48,186 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import("./pages/Settings"));
const AuditLog = lazy(() => import("./pages/AuditLog"));
const NotFound = lazy(() => import("./pages/NotFound"));
const Warehouse = lazy(() => import("./pages/Warehouse"));
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
const WarehouseReceiptDetail = lazy(
() => import("./pages/WarehouseReceiptDetail"),
);
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
const WarehouseReservations = lazy(
() => import("./pages/WarehouseReservations"),
);
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
const WarehouseInventoryDetail = lazy(
() => import("./pages/WarehouseInventoryDetail"),
);
const WarehouseInventoryForm = lazy(
() => import("./pages/WarehouseInventoryForm"),
);
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
export default function AdminApp() {
return (
<AuthProvider>
<AlertProvider>
<AlertContainer />
<ErrorBoundary>
<Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route
path="attendance/create"
element={<AttendanceCreate />}
/>
<Route
path="attendance/location/:id"
element={<AttendanceLocation />}
/>
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AlertContainer />
<ErrorBoundary>
<Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route
path="attendance/admin"
element={<AttendanceAdmin />}
/>
<Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route
path="attendance/requests"
element={<LeaveRequests />}
/>
<Route
path="attendance/approval"
element={<LeaveApproval />}
/>
<Route
path="attendance/create"
element={<AttendanceCreate />}
/>
<Route
path="attendance/location/:id"
element={<AttendanceLocation />}
/>
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route
path="offers/customers"
element={<OffersCustomers />}
/>
<Route
path="offers/templates"
element={<OffersTemplates />}
/>
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
<Route path="warehouse" element={<Warehouse />} />
<Route path="warehouse/items" element={<WarehouseItems />} />
<Route
path="warehouse/items/:id"
element={<WarehouseItemDetail />}
/>
<Route
path="warehouse/receipts"
element={<WarehouseReceipts />}
/>
<Route
path="warehouse/receipts/new"
element={<WarehouseReceiptForm />}
/>
<Route
path="warehouse/receipts/:id"
element={<WarehouseReceiptDetail />}
/>
<Route
path="warehouse/receipts/:id/edit"
element={<WarehouseReceiptForm />}
/>
<Route
path="warehouse/issues"
element={<WarehouseIssues />}
/>
<Route
path="warehouse/issues/new"
element={<WarehouseIssueForm />}
/>
<Route
path="warehouse/issues/:id"
element={<WarehouseIssueDetail />}
/>
<Route
path="warehouse/issues/:id/edit"
element={<WarehouseIssueForm />}
/>
<Route
path="warehouse/reservations"
element={<WarehouseReservations />}
/>
<Route
path="warehouse/inventory"
element={<WarehouseInventory />}
/>
<Route
path="warehouse/inventory/new"
element={<WarehouseInventoryForm />}
/>
<Route
path="warehouse/inventory/:id"
element={<WarehouseInventoryDetail />}
/>
<Route
path="warehouse/reports"
element={<WarehouseReports />}
/>
<Route
path="warehouse/suppliers"
element={<WarehouseSuppliers />}
/>
<Route
path="warehouse/locations"
element={<WarehouseLocations />}
/>
<Route
path="warehouse/categories"
element={<WarehouseCategories />}
/>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
</AlertProvider>
</AuthProvider>
);

View File

@@ -318,12 +318,14 @@
/* Leave Type Badges */
.attendance-leave-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.55rem;
border-radius: var(--border-radius-sm);
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.25rem;
line-height: 1.2;
background: var(--bg-tertiary);
color: var(--text-secondary);
}

View File

@@ -330,15 +330,6 @@ img {
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */
/* Font sizes */

View File

@@ -41,6 +41,11 @@
}
}
/* Danger card — highlighted border for alert/important cards */
.admin-card-danger {
border: 2px solid var(--danger);
}
/* ============================================================================
Badges
============================================================================ */
@@ -123,6 +128,16 @@
color: var(--danger);
}
.admin-badge-incoming {
background: var(--success-soft);
color: var(--success);
}
.admin-badge-outgoing {
background: var(--info-soft);
color: var(--info);
}
/* Status Badges - Leave Requests */
.badge-pending {
background: color-mix(in srgb, var(--warning) 15%, transparent);
@@ -716,6 +731,7 @@
.admin-kpi-grid {
display: grid;
gap: 0.875rem;
margin-bottom: 1rem;
}
.admin-kpi-4 {
@@ -923,3 +939,163 @@
max-height: 80px;
object-fit: contain;
}
/* ============================================================================
Search Bar & Filters
============================================================================ */
.admin-search-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
flex: 1 1 140px;
min-width: 140px;
max-width: 280px;
}
.admin-search-bar .react-datepicker-wrapper {
flex: 1 1 140px;
min-width: 140px;
max-width: 280px;
}
.admin-search-bar .admin-btn {
flex-shrink: 0;
}
.admin-search {
position: relative;
flex: 1 1 200px;
min-width: 180px;
max-width: 320px;
display: flex;
align-items: center;
}
.admin-search svg {
position: absolute;
left: 10px;
color: var(--text-muted);
pointer-events: none;
}
.admin-search .admin-form-input {
padding-left: 32px;
}
@media (max-width: 768px) {
.admin-search-bar {
flex-direction: column;
align-items: stretch;
}
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
max-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
max-width: 100%;
}
.admin-search {
max-width: 100%;
flex: 1 1 100%;
}
}
@media (max-width: 480px) {
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
min-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
min-width: 100%;
}
}
/* ============================================================================
Item Picker (Warehouse)
============================================================================ */
.admin-item-picker-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 240px;
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;
margin: 0;
list-style: none;
}
.admin-item-picker-list::-webkit-scrollbar {
width: 5px;
}
.admin-item-picker-list::-webkit-scrollbar-track {
background: transparent;
}
.admin-item-picker-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.admin-item-picker-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.admin-item-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.admin-item-picker-item:hover,
.admin-item-picker-item.active {
background: var(--bg-secondary);
}
.admin-item-picker-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.admin-item-picker-number {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
}
.admin-item-picker-qty {
font-size: 11.5px;
color: var(--text-secondary);
white-space: nowrap;
}

View File

@@ -1,4 +1,4 @@
import { forwardRef, useMemo } from "react";
import { forwardRef } from "react";
import DatePicker, { registerLocale } from "react-datepicker";
import { cs } from "date-fns/locale";
import { parse, format } from "date-fns";
@@ -124,7 +124,7 @@ export default function AdminDatePicker({
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = useMemo(() => isTouchDevice(), []);
const useNative = isTouchDevice();
if (useNative) {
return (
@@ -178,15 +178,12 @@ export default function AdminDatePicker({
return undefined;
};
const customInput = useMemo(
() => (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
[required, placeholder, disabled],
const customInput = (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
);
const commonProps = {
@@ -194,6 +191,7 @@ export default function AdminDatePicker({
onChange: handleChange,
locale: "cs",
customInput,
placeholderText: placeholder,
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,
@@ -8,34 +8,13 @@ import {
getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number;
shift_date: string;
user_name: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
interface AttendanceRecord extends HookAttendanceRecord {
arrival_lat?: number | string | null;
arrival_lng?: number | string | null;
departure_lat?: number | string | null;
departure_lng?: number | string | null;
project_name?: string;
project_logs?: ProjectLog[];
notes?: string | null;
}
interface AttendanceShiftTableProps {
@@ -51,7 +30,7 @@ function formatBreak(record: AttendanceRecord): string {
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`;
}
return "\u2014";
return "";
}
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
@@ -97,7 +76,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
>
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})`
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " " : ""})`
: "—"}
</span>
);
@@ -115,7 +94,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
</span>
);
}
return "\u2014";
return "";
}
export default function AttendanceShiftTable({
@@ -156,7 +135,10 @@ export default function AttendanceShiftTable({
const workMinutes = isLeave
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes(record);
: calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
@@ -173,18 +155,16 @@ export default function AttendanceShiftTable({
</span>
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
{isLeave ? "" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatBreak(record)}
{isLeave ? "" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
{isLeave ? "" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? `${formatMinutes(workMinutes)} h`
: "\u2014"}
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
@@ -195,10 +175,10 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
<span aria-hidden="true">{"\uD83D\uDCCD"}</span>
<span aria-hidden="true">{"📍"}</span>
</Link>
) : (
"\u2014"
""
)}
</td>
<td

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps {
@@ -91,6 +91,19 @@ export default function ConfirmModal({
confirmVariant,
loading,
}: ConfirmModalProps) {
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, loading, onClose]);
return (
<AnimatePresence>
{isOpen && (

View File

@@ -0,0 +1,118 @@
import { useEffect, type ReactNode, type FormEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useModalLock from "../hooks/useModalLock";
export interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: boolean;
hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true
closeOnEsc?: boolean; // default true
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = false,
hideFooter = false,
size = "md",
closeOnBackdrop = true,
closeOnEsc = true,
}: FormModalProps) {
useModalLock(isOpen);
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeOnEsc, loading, onClose]);
const handleBackdropClick = () => {
if (closeOnBackdrop && !loading) onClose();
};
const handlePrimaryClick = (e: FormEvent) => {
e.preventDefault();
if (onSubmit) onSubmit();
};
const titleId = "form-modal-title";
const modalClassName =
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
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={handleBackdropClick} />
<motion.div
className={modalClassName}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
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={titleId} className="admin-modal-title">
{title}
</h2>
</div>
<div className="admin-modal-body">{children}</div>
{!hideFooter && (
<div className="admin-modal-footer">
<button
type="button"
onClick={() => !loading && onClose()}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelLabel}
</button>
{onSubmit && (
<button
type="button"
onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : submitLabel}
</button>
)}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { projectFilesOptions } from "../lib/queries/projects";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "./ConfirmModal";
import apiFetch from "../utils/api";
@@ -196,14 +198,11 @@ export default function ProjectFileManager({
hasNasFolder,
}: ProjectFileManagerProps) {
const alert = useAlert();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [currentPath, setCurrentPath] = useState("");
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
const [fullPath, setFullPath] = useState("");
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -217,59 +216,25 @@ export default function ProjectFileManager({
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
const [deleting, setDeleting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const canManage = hasPermission("projects.files");
const fetchFiles = useCallback(
async (path = "", options: { ignore?: boolean } = {}) => {
setLoading(true);
setErrorMessage(null);
try {
const params = new URLSearchParams({ project_id: String(projectId) });
if (path) {
params.set("path", path);
}
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
if (options.ignore) return;
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
setItems(data.data.items || []);
setBreadcrumb(data.data.breadcrumb || [""]);
setCurrentPath(data.data.path || "");
setFullPath(data.data.full_path || "");
} else if (res.status === 404) {
setItems([]);
setBreadcrumb([""]);
} else {
setErrorMessage(data.error || "Nepodařilo se načíst soubory");
}
} catch {
if (!options.ignore) {
setErrorMessage("Chyba připojení");
}
} finally {
if (!options.ignore) {
setLoading(false);
}
}
},
[projectId],
);
useEffect(() => {
const opts = { ignore: false };
fetchFiles("", opts);
return () => {
opts.ignore = true;
};
}, [fetchFiles]);
const {
data: filesData,
isPending: filesLoading,
error: filesError,
} = useQuery(projectFilesOptions(projectId, currentPath));
const items = filesData?.items ?? [];
const breadcrumb = filesData?.breadcrumb ?? [""];
const fullPath = filesData?.full_path ?? "";
const errorMessage = filesError
? filesError.message || "Nepodařilo se načíst soubory"
: null;
const navigateTo = (path: string) => {
setNewFolderMode(false);
setRenamingItem(null);
fetchFiles(path);
setCurrentPath(path);
};
const handleBreadcrumbClick = (index: number) => {
@@ -332,7 +297,9 @@ export default function ProjectFileManager({
? "Soubor byl nahrán"
: `Nahráno ${successCount} souborů`;
alert.success(msg);
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
}
if (errorMsg) {
alert.error(errorMsg);
@@ -383,7 +350,9 @@ export default function ProjectFileManager({
alert.success("Složka byla vytvořena");
setNewFolderMode(false);
setNewFolderName("");
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se vytvořit složku");
}
@@ -444,7 +413,9 @@ export default function ProjectFileManager({
? "Složka byla smazána"
: "Soubor byl smazán",
);
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se smazat");
}
@@ -479,7 +450,9 @@ export default function ProjectFileManager({
const data = await res.json();
if (data.success) {
alert.success("Přejmenováno");
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se přejmenovat");
}
@@ -495,31 +468,10 @@ export default function ProjectFileManager({
setRenameValue(item.name);
};
if (loading && items.length === 0 && !errorMessage) {
if (filesLoading && items.length === 0 && !errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{
width: "18px",
height: "18px",
borderRadius: "4px",
flexShrink: 0,
}}
/>
<div
className="admin-skeleton-line"
style={{ width: `${60 + i * 10}%` }}
/>
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -710,7 +662,7 @@ export default function ProjectFileManager({
</div>
)}
{items.length === 0 && !loading ? (
{items.length === 0 && !filesLoading ? (
<div className="fm-empty">
<svg
width="32"

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react";
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,11 @@ export default function RichEditor({
[onChange],
);
useEffect(() => {
useLayoutEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.root.style.fontSize = "14px";
}, []);
return (

View File

@@ -85,7 +85,15 @@ export interface ShiftFormModalProps {
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(projectLogs);
const totalProject = calcProjectMinutesTotal(
projectLogs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id);
@@ -329,7 +337,6 @@ export default function ShiftFormModal({
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>

View File

@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
{
path: "/attendance/admin",
label: "Správa",
permission: "attendance.admin",
permission: "attendance.manage",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
{
path: "/trips/admin",
label: "Správa",
permission: "trips.admin",
permission: "trips.manage",
icon: (
<svg
viewBox="0 0 24 24"
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
{
path: "/vehicles",
label: "Vozidla",
permission: "trips.vehicles",
permission: "vehicles.manage",
icon: (
<svg
viewBox="0 0 24 24"
@@ -315,7 +315,202 @@ const menuSections: MenuSection[] = [
{
path: "/offers/customers",
label: "Zákazníci",
permission: "offers.view",
permission: "customers.view",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
},
],
},
{
label: "Sklad",
items: [
{
path: "/warehouse",
label: "Přehled",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
path: "/warehouse/items",
label: "Položky",
permission: "warehouse.view",
matchPrefix: "/warehouse/items",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
path: "/warehouse/receipts",
label: "Příjmy",
permission: "warehouse.operate",
matchPrefix: "/warehouse/receipts",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
<path d="M5 19h14" />
</svg>
),
},
{
path: "/warehouse/issues",
label: "Výdeje",
permission: "warehouse.operate",
matchPrefix: "/warehouse/issues",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
<path d="M5 5h14" />
</svg>
),
},
{
path: "/warehouse/reservations",
label: "Rezervace",
permission: "warehouse.operate",
matchPrefix: "/warehouse/reservations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
),
},
{
path: "/warehouse/inventory",
label: "Inventura",
permission: "warehouse.inventory",
matchPrefix: "/warehouse/inventory",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
},
{
path: "/warehouse/reports",
label: "Reporty",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
},
{
path: "/warehouse/categories",
label: "Kategorie",
permission: "warehouse.manage",
matchPrefix: "/warehouse/categories",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
),
},
{
path: "/warehouse/locations",
label: "Lokace",
permission: "warehouse.manage",
matchPrefix: "/warehouse/locations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
},
{
path: "/warehouse/suppliers",
label: "Dodavatelé",
permission: "warehouse.manage",
matchPrefix: "/warehouse/suppliers",
icon: (
<svg
viewBox="0 0 24 24"
@@ -356,7 +551,13 @@ const menuSections: MenuSection[] = [
{
path: "/settings",
label: "Nastavení",
permission: "settings.manage",
permission: [
"settings.company",
"settings.banking",
"settings.roles",
"settings.system",
"settings.templates",
],
icon: (
<svg
viewBox="0 0 24 24"

View File

@@ -89,6 +89,21 @@ export default function DashProfile({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
if (dataToSave.new_password && !dataToSave.current_password) {
alert.error("Pro změnu hesla zadejte aktuální heslo");
return;
}
if (dataToSave.current_password && !dataToSave.new_password) {
alert.error("Pro změnu hesla zadejte nové heslo");
return;
}
// Strip empty password fields so Zod doesn't reject ""
if (!dataToSave.current_password)
delete (dataToSave as any).current_password;
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
try {
const response = await apiFetch(`${API_BASE}/profile`, {
method: "PUT",
@@ -329,9 +344,24 @@ export default function DashProfile({
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Aktuální heslo</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte aktuální heslo"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Nové heslo (ponechte prázdné pro zachování stávajícího)
Nové heslo
</label>
<input
type="password"
@@ -343,27 +373,9 @@ export default function DashProfile({
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</div>
{formData.new_password && (
<div className="admin-form-group">
<label className="admin-form-label required">
Aktuální heslo
</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Zadejte aktuální heslo pro potvrzení"
/>
</div>
)}
</div>
</div>
<div className="admin-modal-footer">

View File

@@ -1,27 +1,15 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
const API_BASE = "/api/admin";
interface DeviceInfo {
icon?: string;
browser?: string;
os?: string;
}
interface Session {
id: number | string;
is_current: boolean;
device_info?: DeviceInfo;
ip_address: string;
created_at: string;
}
interface DeleteModalState {
isOpen: boolean;
session: Session | null;
@@ -77,9 +65,10 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
@@ -89,26 +78,6 @@ export default function DashSessions() {
useModalLock(deleteAllModal);
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions`);
const data = await response.json();
if (data.success) {
setSessions(
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
);
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
fetchSessions();
}, [fetchSessions]);
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return;
@@ -122,7 +91,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null });
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || "Nepodařilo se ukončit relaci");
@@ -143,7 +112,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteAllModal(false);
setSessions((prev) => prev.filter((s) => s.is_current));
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || "Nepodařilo se ukončit relace");
@@ -183,97 +152,83 @@ export default function DashSessions() {
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && (
<div
className="admin-skeleton"
style={{ padding: "1rem", gap: "1rem" }}
>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
</div>
</div>
))}
{sessionsLoading ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
<div className="dash-sessions-list">
{sessions.map((session) => (
) : (
<>
{sessions.length === 0 && (
<div
key={session.id}
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
<div className="dash-session-icon">
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="dash-session-info">
<div className="dash-session-device">
{session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="dash-session-meta">
<span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="dash-session-actions">
{!session.is_current && (
<button
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
</div>
Žádné aktivní relace
</div>
))}
</div>
)}
{sessions.length > 0 && (
<div className="dash-sessions-list">
{sessions.map((session) => (
<div
key={session.id}
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
>
<div className="dash-session-icon">
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="dash-session-info">
<div className="dash-session-device">
{session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="dash-session-meta">
<span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="dash-session-actions">
{!session.is_current && (
<button
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</motion.div>

View File

@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<div>
{response && response.reserved_quantity > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</div>
)}
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
onChange: (itemId: number) => void;
itemName?: string;
}
export default function ItemPicker({
value,
onChange,
itemName,
}: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef<number>(-1);
const listboxId = useId();
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [dropdownStyle, setDropdownStyle] = useState<{
position: "fixed";
top: number;
left: number;
width: number;
zIndex: number;
} | null>(null);
const updatePosition = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 2,
left: rect.left,
width: rect.width,
zIndex: 100,
});
}, []);
// Reset active index when items change
useEffect(() => {
if (activeIndex >= items.length) {
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [items, activeIndex]);
useEffect(() => {
if (open) {
updatePosition();
const onScroll = () => updatePosition();
const onClose = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
} else {
setDropdownStyle(null);
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [open, updatePosition]);
// Close on click-outside via mousedown on document
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target;
if (containerRef.current && target instanceof Node) {
if (!containerRef.current.contains(target)) {
// Don't close if click landed on an option in the portal
const listEl = document.getElementById(listboxId);
if (listEl && listEl.contains(target)) return;
setOpen(false);
}
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open, listboxId]);
const handleSelect = (itemId: number, name: string) => {
onChange(itemId);
setOpen(false);
setSearch(name);
};
const setActive = (index: number) => {
activeIndexRef.current = index;
setActiveIndex(index);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(0);
return;
}
const next =
activeIndexRef.current < items.length - 1
? activeIndexRef.current + 1
: 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(items.length - 1);
return;
}
const prev =
activeIndexRef.current > 0
? activeIndexRef.current - 1
: items.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
if (
open &&
activeIndexRef.current >= 0 &&
activeIndexRef.current < items.length
) {
e.preventDefault();
const item = items[activeIndexRef.current];
handleSelect(item.id, item.name);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
} else if (e.key === "Home") {
if (open && items.length > 0) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && items.length > 0) {
e.preventDefault();
setActive(items.length - 1);
}
} else if (e.key === "Tab") {
setOpen(false);
}
};
const activeId =
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
return (
<div
ref={containerRef}
style={{ position: "relative" }}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-owns={listboxId}
>
<input
type="text"
className="admin-form-input"
placeholder="Hledat položku..."
value={search}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeId}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => {
setOpen(true);
updatePosition();
}}
onKeyDown={handleKeyDown}
/>
{open &&
items.length > 0 &&
dropdownStyle &&
createPortal(
<ul
id={listboxId}
role="listbox"
className="admin-item-picker-list"
style={dropdownStyle}
>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={value === item.id}
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item.id, item.name);
}}
>
<span className="admin-item-picker-name">{item.name}</span>
{item.item_number && (
<span className="admin-item-picker-number">
{item.item_number}
</span>
)}
{item.available_quantity !== undefined && (
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>,
document.body,
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez lokace --</option>
{locations?.map((loc: WarehouseLocation) => (
<option key={loc.id} value={loc.id}>
{loc.code} - {loc.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
interface ReservationPickerProps {
itemId: number | null;
projectId: number | null;
value: number | null;
onChange: (reservationId: number | null, remainingQty: number) => void;
}
export default function ReservationPicker({
itemId,
projectId,
value,
onChange,
}: ReservationPickerProps) {
const { data: result } = useQuery(
warehouseReservationListOptions({
item_id: itemId ?? undefined,
project_id: projectId ?? undefined,
status: "ACTIVE",
perPage: 100,
}),
);
const reservations = result?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val) {
onChange(null, 0);
return;
}
const reservationId = Number(val);
const reservation = reservations.find((r) => r.id === reservationId);
if (reservation) {
onChange(reservationId, Number(reservation.remaining_qty));
}
}}
>
<option value=""> žádná rezervace </option>
{reservations.map((r) => (
<option key={r.id} value={r.id}>
R{r.id} {r.project?.name ?? `Projekt #${r.project_id}`} zbývá:{" "}
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez dodavatele --</option>
{suppliers.map((s: WarehouseSupplier) => (
<option key={s.id} value={s.id}>
{s.name}
{s.ico ? ` (${s.ico})` : ""}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,249 @@
import { ReactNode, useId, useRef } from "react";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
export type MovementRowRenderer<T> = (
item: T,
index: number,
updateItem: (field: string, value: unknown) => void,
removeItem: () => void,
) => ReactNode;
interface WarehouseMovementTableProps<T extends { key: string }> {
items: T[];
onChange: (items: T[]) => void;
mode: "receipt" | "issue" | "inventory";
defaultItem?: () => Omit<T, "key">;
renderRow?: MovementRowRenderer<T>;
renderHeader?: () => ReactNode;
}
function parseDecimal(raw: string): number {
// Strip leading minus if you want negative support; here we want only positive
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
});
export default function WarehouseMovementTable<T extends { key: string }>({
items,
onChange,
mode,
defaultItem,
renderRow,
renderHeader,
}: WarehouseMovementTableProps<T>) {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const addItem = () => {
const factory =
defaultItem ??
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
};
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: string, value: unknown) => {
const updated = [...items];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
// If a custom row renderer is supplied, the caller is fully in charge of
// the row layout (and typically the column headers too — see inventory mode).
if (renderRow) {
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
{renderHeader && (
<thead>
<tr>{renderHeader()}</tr>
</thead>
)}
<tbody>
{items.map((item, index) => (
<tr key={item.key}>
{renderRow(
item,
index,
(field, value) => updateItem(index, field, value),
() => removeItem(index),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
const movementItems = items as unknown as MovementItem[];
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th className="admin-warehouse-col-item">Položka</th>
{mode === "issue" && (
<th className="admin-warehouse-col-batch">Šarže</th>
)}
<th className="admin-warehouse-col-qty">Množství</th>
<th className="admin-warehouse-col-price">Cena/ks</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-notes">Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{movementItems.map((item, index) => (
<tr key={item.key}>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
{mode === "issue" && (
<td className="admin-warehouse-col-batch">
<BatchPicker
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
</td>
)}
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.quantity || ""}
onChange={(e) =>
updateItem(
index,
"quantity",
parseDecimal(e.target.value),
)
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Množství, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-price">
<input
type="number"
className="admin-form-input"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
min="0"
step="0.01"
disabled={mode === "issue"}
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Cena za kus, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(id) => updateItem(index, "location_id", id)}
/>
</td>
<td className="admin-warehouse-col-notes">
<input
type="text"
className="admin-form-input"
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}

View File

@@ -90,6 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const cachedUserRef = useRef<User | null>(null);
const sessionFetchedRef = useRef(false);
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
const hadValidSessionRef = useRef(false);
const [user, setUser] = useState<User | null>(cachedUserRef.current);
const [loading, setLoading] = useState(!sessionFetchedRef.current);
const [error, setError] = useState<string | null>(null);
@@ -138,13 +139,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.success && data.data?.access_token) {
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
hadValidSessionRef.current = true;
return true;
}
accessTokenRef.current = null;
tokenExpiresAtRef.current = null;
setUser(null);
cachedUserRef.current = null;
setSessionExpired();
if (hadValidSessionRef.current) setSessionExpired();
return false;
} catch {
// Network error — don't kick the user out, just return false
@@ -178,6 +180,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
hadValidSessionRef.current = true;
return true;
}
}
@@ -233,6 +236,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true };
}
setError(data.error);
@@ -273,6 +277,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true };
}
setError(data.error);
@@ -302,6 +307,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
cachedUserRef.current = null;
sessionFetchedRef.current = false;
hadValidSessionRef.current = false;
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;

View File

@@ -1,49 +0,0 @@
import { useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
interface ApiCallResult<T> {
data: T | null;
ok: boolean;
response: Response | null;
}
export default function useApiCall() {
const alert = useAlert();
const abortRef = useRef<AbortController | null>(null);
const call = useCallback(
async <T = unknown>(
url: string,
options: RequestInit = {},
errorMsg = "Chyba při načítání dat",
): Promise<ApiCallResult<T>> => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, {
...restOptions,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok || !data.success) {
alert.error(data.error || errorMsg);
return { data: null, ok: false, response };
}
return { data: data.data as T, ok: true, response };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return { data: null, ok: false, response: null };
}
alert.error(errorMsg);
return { data: null, ok: false, response: null };
}
},
[alert],
);
return { call };
}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { isHoliday } from "../../utils/czech-holidays";
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
@@ -9,9 +11,17 @@ import {
formatDate,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
ShiftFormData,
@@ -28,7 +38,7 @@ interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void };
}
interface AttendanceRecord {
export interface AttendanceRecord {
id: number;
user_id: number;
shift_date: string;
@@ -38,7 +48,7 @@ interface AttendanceRecord {
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
notes?: string | null;
project_id?: number | null;
project_name?: string;
project_logs?: Array<{
@@ -72,7 +82,6 @@ interface UserTotal {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
overtime: number;
missing: number;
@@ -80,6 +89,16 @@ interface UserTotal {
business_days: number;
worked_hours: number;
covered: number;
// mzda-style summary fields (set by computeUserTotals)
worked_hours_raw?: number;
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
records?: AttendanceRecord[];
}
interface LeaveBalance {
@@ -120,6 +139,11 @@ const combineDatetime = (date: string, time: string): string | null =>
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
*
* Adds mzda-style summary fields (odpracovano, vcetne_svatku, prescas, svatek,
* weekend_hours, night_minutes) and a fund computed as
* (rawBizDays + holidayCount) × 8 — holidays count as work days for fund
* purposes, per the mzda.pdf model.
*/
function computeUserTotals(
records: AttendanceRecord[],
@@ -127,6 +151,7 @@ function computeUserTotals(
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {};
const recordsByUser = new Map<number, AttendanceRecord[]>();
for (const rec of records) {
const uid = String(rec.user_id);
@@ -143,7 +168,6 @@ function computeUserTotals(
working: false,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
@@ -151,15 +175,23 @@ function computeUserTotals(
business_days: 0,
worked_hours: 0,
covered: 0,
records: [],
};
}
const t = totals[uid];
t.records!.push(rec);
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
recordsByUser.get(rec.user_id)!.push(rec);
const leaveType = rec.leave_type || "work";
if (leaveType === "work") {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes(rec);
t.minutes += calculateWorkMinutes({
...rec,
notes: rec.notes ?? undefined,
});
} else {
const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) {
@@ -169,12 +201,14 @@ function computeUserTotals(
case "sick":
t.sick_hours += leaveHours;
break;
case "holiday":
t.holiday_hours += leaveHours;
break;
case "unpaid":
t.unpaid_hours += leaveHours;
break;
// "holiday" leave_type is no longer selectable in the UI — it is
// auto-computed from Czech public holidays (see freeHolidayHours
// below). Old records may still exist in the DB; treat them as a
// paid non-work entry and skip so they don't double-count with
// the auto-detected svátek.
}
}
@@ -189,7 +223,10 @@ function computeUserTotals(
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// Count business days in month (Mon-Fri)
// Count Mon-Fri business days in month (INCLUDING holidays).
// The fund is rawBizDays × 8 — holidays stay in the count so the
// user is paid for them via the fund. Svátek (free holiday hours) is
// computed separately as "8h per holiday the user did not work".
let rawBizDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
@@ -198,23 +235,100 @@ function computeUserTotals(
cur.setDate(cur.getDate() + 1);
}
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) {
const t = totals[uid];
// Subtract holiday days from business days for this user
const holidayDays = Math.round(t.holiday_hours / 8);
const bizDays = Math.max(0, rawBizDays - holidayDays);
const fund = bizDays * 8;
const workedHours = Math.round((t.minutes / 60) * 10) / 10;
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
const leaveHours = t.vacation_hours + t.sick_hours;
const covered = Math.round((workedHours + leaveHours) * 10) / 10;
const userRecords = recordsByUser.get(Number(uid)) || [];
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
const workedHoursRaw = t.minutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
const weekendHours = userRecords
.filter(
(r) =>
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
// Práce v noci: minutes in 22:00-06:00 across all work records.
const nightMinutes = userRecords
.filter((r) => (r.leave_type || "work") === "work")
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
// Svátek (free): 8h per holiday the user did not work.
const freeHolidayHours = calculateFreeHolidayHours(
userRecords,
holidayDates,
);
// Worked holidays: holidays the user DID work (counted toward
// Odpracováno, but their 8h must not flow into Přesčas).
const workedDatesSet = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let workedHolidayCount = 0;
let workedHolidayHours = 0;
for (const hd of holidayDates) {
if (workedDatesSet.has(hd)) workedHolidayCount++;
}
// Sum the actual hours worked on holiday dates (for the KPI card's
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
for (const r of userRecords) {
if ((r.leave_type || "work") !== "work") continue;
const d = normalizeDateStr(r.shift_date);
if (d && holidayDates.includes(d)) {
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
}
}
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
const fund = rawBizDays * 8;
// Včetně svátků a přesčasů (mzda formula):
// odpracovano + vacation + remainder min(freeHols, workedHols × 8)
//
// Two-way logic matching the print:
// • If the user worked at least one holiday, that worked holiday's
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
// subtract one 8h per worked holiday (capped at the total free
// holiday hours — can't go negative).
// • If the user did NOT work any holiday, no adjustment is needed:
// all unworked holidays are already paid via the Svátek line and
// don't flow into Přesčas, so vcetne_svatku just equals the
// work + vacation + remainder.
const remainder = workedHoursRaw - odpracovano;
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
// Přesčas = vcetneSv odpracovano (derived).
const prescas = vcetneSv - odpracovano;
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = fund;
t.business_days = bizDays;
t.business_days = rawBizDays;
t.worked_hours = workedHours;
t.covered = covered;
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
t.worked_hours_raw = workedHoursRaw;
t.odpracovano = odpracovano;
t.vcetne_svatku = vcetneSv;
t.prescas = prescas;
t.svatek = freeHolidayHours;
t.weekend_hours = weekendHours;
t.night_minutes = nightMinutes;
t.worked_holiday_hours = workedHolidayHours;
}
return totals;
@@ -233,14 +347,6 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0)
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">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>';
}
function buildProjectLogsHtml(record: Record<string, any>): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
@@ -271,94 +377,220 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—");
}
function buildLeaveSummaryHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const bal = printData.leave_balances[userId];
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: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0)
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: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`;
}
function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const leaveHtml = printData.leave_balances[userId]
? buildLeaveSummaryHtml(userId, userData, printData)
: "";
// Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, Record<string, any>>();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
const recordRows = (userData.records || [])
.map((record: any) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
// Iterate one row per day of the month.
const [yearStr, monthStr] = printData.month.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
return `<tr>
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
const userRecords = (userData.records || []) as Record<string, any>[];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const record = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
const leaveType = (record?.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
if (!record) {
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr>`);
continue;
}
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</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 ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td class="text-center">${hoursCell}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`;
})
.join("");
</tr>`);
}
const fundRow =
userData.fund !== null
? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
<td colspan="2">${renderFundStatus(userData)}</td>
</tr>`
: "";
// ----- mzda-style summary numbers (computed here, not from userData,
// because the backend's getPrintData only returns the legacy fields). -----
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
let workedMinutes = 0;
let weekendHoursRaw = 0;
let nightMinutes = 0;
for (const r of userRecords) {
const leaveType = r.leave_type || "work";
if (leaveType !== "work") continue;
const m = calculateWorkMinutesPrint(r);
workedMinutes += m;
if (isWeekendDate(r.shift_date)) {
weekendHoursRaw += m / 60;
}
nightMinutes += calculateNightMinutes(r);
}
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
const workedHoursRaw = workedMinutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let freeHolidayHours = 0;
let workedHolidayCount = 0;
for (const hd of holidayDates) {
if (workedDates.has(hd)) workedHolidayCount++;
else freeHolidayHours += 8;
}
// Fund: count Mon-Fri (incl. holidays) × 8h.
let rawBizDays = 0;
const cur = new Date(yr, mo - 1, 1);
while (cur.getMonth() === mo - 1) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) rawBizDays++;
cur.setDate(cur.getDate() + 1);
}
const businessDays = rawBizDays;
const fund = businessDays * 8;
// Včetně svátků a přesčasů: floor(worked/8)*8 worked_holiday*8 + vacation + remainder.
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
// into Přesčas — so we subtract it here. Unworked holidays are paid via
// the fund and don't affect this line.)
const remainder = workedHoursRaw - odpracovano;
const vcetneSv =
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
// Přesčas = #2 - #1.
const prescas = vcetneSv - odpracovano;
const summaryHtml = `<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>`;
return `<div class="user-section">
<div class="user-header">
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
${leaveHtml}
<table>
<thead><tr>
<th style="width:70px">Datum</th>
<th style="width:70px">Typ</th>
<th class="text-center" style="width:70px">Příchod</th>
<th class="text-center" style="width:90px">Pauza</th>
<th class="text-center" style="width:70px">Odchod</th>
<th class="text-center" style="width:80px">Hodiny</th>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${recordRows}</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td>
</tr>
${fundRow}
</tfoot>
<tbody>${rows.join("")}</tbody>
</table>
${summaryHtml}
</div>`;
}
@@ -376,7 +608,13 @@ function buildPrintHtml(
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
@@ -406,19 +644,47 @@ function buildPrintHtml(
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
/* Weekend and holiday row highlighting — must come after the
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
.user-section tr.weekend td { background: #fde68a; }
.user-section tr.holiday td { background: #bbf7d0; }
.user-section tr.weekend.holiday td { background: #fcd34d; }
.user-section tr.vacation td { background: #bfdbfe; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary {
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
border: 1px solid #ddd; font-size: 10px;
.user-summary {
margin-top: 10px; padding: 10px 15px;
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
}
.user-summary .summary-row {
display: flex; justify-content: space-between; padding: 2px 0;
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
.legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 8px; padding-top: 6px;
border-top: 1px dashed #ddd;
font-size: 9px; color: #555;
}
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
.legend-swatch {
display: inline-block; width: 12px; height: 12px;
border: 1px solid #333; vertical-align: middle;
}
.legend-swatch.weekend { background: #fde68a; }
.legend-swatch.holiday { background: #bbf7d0; }
.legend-swatch.weekend-holiday { background: #fcd34d; }
.legend-swatch.vacation { background: #bfdbfe; }
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@@ -461,6 +727,7 @@ function buildPrintHtml(
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
const queryClient = useQueryClient();
// ---- Core state ----
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
@@ -662,7 +929,15 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
formData: ShiftFormData,
): boolean => {
const totalWork = calcFormWorkMinutes(formData);
const totalProject = calcProjectMinutesTotal(logs);
const totalProject = calcProjectMinutesTotal(
logs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60);
const wM = totalWork % 60;
@@ -771,6 +1046,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -842,6 +1118,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -865,7 +1142,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
record.users.username
: ((record as Record<string, unknown>).user_name as string) ||
: ((record as unknown as Record<string, unknown>).user_name as string) ||
`User #${record.user_id}`;
const enriched = { ...record, user_name: userName };
setEditingRecord(enriched);
@@ -976,6 +1253,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1005,6 +1283,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -1,139 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import useDebounce from "./useDebounce";
const API_BASE = "/api/admin";
interface PaginationData {
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface UseListDataOptions {
dataKey?: string;
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
extraParams?: Record<string, string>;
errorMsg?: string;
}
export default function useListData<T = unknown>(
endpoint: string,
options: UseListDataOptions = {},
) {
const {
dataKey,
search = "",
sort,
order,
page = 1,
perPage = 25,
extraParams = {},
errorMsg = "Nepodařilo se načíst data",
} = options;
const alert = useAlert();
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
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();
abortRef.current = controller;
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (debouncedSearch) params.set("search", debouncedSearch);
if (sort) params.set("sort", sort);
if (order) params.set("order", order);
Object.entries(extraParams).forEach(([k, v]) => {
if (v) params.set(k, v);
});
const url = endpoint.startsWith("/")
? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`;
const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) {
window.location.href = "/login";
return;
}
const result = await response.json();
if (result.success) {
const data = dataKey
? result.data[dataKey]
: Array.isArray(result.data)
? result.data
: result.data?.items || [];
setItems(data || []);
const pag =
result.pagination ||
(!Array.isArray(result.data) && result.data?.pagination) ||
null;
setPagination(
pag || {
total: data?.length ?? 0,
page,
per_page: perPage,
total_pages: 1,
},
);
} else {
alert.error(result.error || errorMsg);
}
} 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);
}
}, [
endpoint,
debouncedSearch,
sort,
order,
page,
perPage,
dataKey,
extraParamsKey,
]);
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort();
};
}, [fetchData]);
return {
items,
setItems,
loading,
initialLoad,
pagination,
refetch: fetchData,
};
}

View File

@@ -0,0 +1,44 @@
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import type { UseQueryOptions } from "@tanstack/react-query";
interface PaginatedResult<T> {
data: T[];
pagination: {
total: number;
page: number;
per_page: number;
total_pages: number;
};
}
/**
* Wrapper around useQuery for paginated list endpoints.
* Accepts the return value of queryOptions() from lib/queries/*
* and extracts items + pagination from the response.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function usePaginatedQuery<T>(options: any) {
const query = useQuery({
...options,
placeholderData: keepPreviousData,
} as UseQueryOptions<PaginatedResult<T>>);
const data = query.data as PaginatedResult<T> | undefined;
return {
items: (data?.data ?? []) as T[],
pagination: data?.pagination ?? null,
isPending: query.isPending,
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
export { useQueryClient, useQuery, keepPreviousData };

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
/**
* Returns true when the user has expressed a preference for reduced motion
* (OS-level setting: `prefers-reduced-motion: reduce`).
*
* SSR-safe: starts as `false` (the default state), so the first render uses
* the normal animation. The effect then reconciles with the live matchMedia
* state on the client and re-renders if needed.
*/
export default function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}

View File

@@ -0,0 +1,83 @@
import apiFetch from "../utils/api";
/**
* Thin adapter that converts apiFetch responses into the shape TanStack Query expects.
* - Checks response.ok and result.success
* - Throws on errors so TanStack Query can handle retry/error states
* - Returns result.data directly (unwrapped from the API envelope)
*/
export async function jsonQuery<T>(
url: string,
options?: RequestInit,
): Promise<T> {
const response = await apiFetch(url, options);
if (response.status === 401) {
throw new Error("Unauthorized");
}
let result: { success: boolean; data?: unknown; error?: string };
try {
result = (await response.json()) as typeof result;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
throw new Error(result.error || `Request failed (${response.status})`);
}
return result.data as T;
}
export interface PaginationMeta {
total: number;
page: number;
per_page: number;
total_pages: number;
}
export interface PaginatedResult<T> {
data: T[];
pagination: PaginationMeta;
}
export async function paginatedJsonQuery<T>(
url: string,
options?: RequestInit,
): Promise<PaginatedResult<T>> {
const response = await apiFetch(url, options);
if (response.status === 401) {
throw new Error("Unauthorized");
}
let result: {
success: boolean;
data?: unknown;
error?: string;
pagination?: PaginationMeta;
};
try {
result = (await response.json()) as typeof result;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
throw new Error(result.error || `Request failed (${response.status})`);
}
const items = Array.isArray(result.data)
? result.data
: ((result.data as { items?: T[] })?.items ?? []);
const pagination = result.pagination ??
(result.data as { pagination?: PaginationMeta })?.pagination ?? {
total: items.length,
page: 1,
per_page: items.length,
total_pages: 1,
};
return { data: items as T[], pagination };
}

View File

@@ -0,0 +1,185 @@
import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";
interface LocationRaw {
users?: { first_name: string; last_name: string };
user_name?: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export interface LocationRecord {
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export interface ProjectLogEntry {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
export interface AttendanceRecord {
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLogEntry[];
}
export interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
export interface UserShort {
id: number | string;
name: string;
}
export interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
export interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
export interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
export interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
export interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
export interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
export interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
export interface ProjectData {
months: Record<string, MonthProjectData>;
}
export const attendanceHistoryOptions = (filters: {
month?: string;
userId?: number;
}) =>
queryOptions({
queryKey: ["attendance", "history", filters],
queryFn: () => {
const params = new URLSearchParams();
params.set("limit", "1000");
if (filters.month) params.set("month", filters.month);
if (filters.userId) params.set("user_id", String(filters.userId));
return jsonQuery<AttendanceRecord[]>(
`/api/admin/attendance?${params.toString()}`,
);
},
});
export const attendanceBalancesOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "balances", year],
queryFn: () =>
jsonQuery<BalancesData>(
`/api/admin/attendance?action=balances&year=${year}`,
),
});
export const attendanceWorkFundOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "workfund", year],
queryFn: () =>
jsonQuery<FundData>(`/api/admin/attendance?action=workfund&year=${year}`),
});
export const attendanceProjectReportOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "project-report", year],
queryFn: () =>
jsonQuery<ProjectData>(
`/api/admin/attendance?action=project_report&year=${year}`,
),
});
export const attendanceLocationOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["attendance", "location", id],
queryFn: async (): Promise<LocationRecord> => {
const response = await apiFetch(
`/api/admin/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Záznam nebyl nalezen");
}
const raw = (result.data.record || result.data) as LocationRaw;
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
const { users: _users, ...rest } = raw;
return { ...rest, user_name: userName };
},
enabled: !!id,
});

View File

@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const auditLogOptions = (filters: {
search?: string;
action?: string;
entityType?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
}) =>
queryOptions({
queryKey: ["audit-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entityType) params.set("entity_type", filters.entityType);
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
if (filters.dateTo) params.set("date_to", filters.dateTo);
if (filters.page) params.set("page", String(filters.page));
const qs = params.toString();
return jsonQuery<{
data: Record<string, unknown>[];
pagination: Record<string, unknown>;
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface BankAccount {
id: number;
account_name: string;
bank_name: string;
account_number: string;
iban: string;
bic: string;
currency: string;
is_default: boolean;
}
export const bankAccountsOptions = () =>
queryOptions({
queryKey: ["bank-accounts"],
queryFn: () => jsonQuery<BankAccount[]>("/api/admin/bank-accounts"),
staleTime: 2 * 60_000,
});
export const supplierListOptions = () =>
queryOptions({
queryKey: ["suppliers"],
queryFn: () =>
jsonQuery<string[]>("/api/admin/received-invoices/suppliers"),
staleTime: 2 * 60_000,
});

View File

@@ -0,0 +1,42 @@
import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";
export const dashboardOptions = () =>
queryOptions({
queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000,
});
export const require2FAOptions = () =>
queryOptions({
queryKey: ["settings", "2fa"],
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});
export interface Session {
id: number | string;
is_current: boolean;
device_info?: {
icon?: string;
browser?: string;
os?: string;
};
ip_address: string;
created_at: string;
}
export const sessionsOptions = () =>
queryOptions({
queryKey: ["sessions"],
queryFn: async (): Promise<Session[]> => {
const response = await apiFetch("/api/admin/sessions");
const data = await response.json();
if (data.success) {
return Array.isArray(data.data) ? data.data : data.data?.sessions || [];
}
throw new Error(data.error || "Nepodařilo se načíst relace");
},
});

View File

@@ -0,0 +1,209 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface CurrencyAmount {
amount: number;
currency: string;
}
export interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
export interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
export interface InvoiceItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
vat_rate?: number;
is_included_in_total: boolean;
position?: number;
}
export interface InvoiceDetail {
id: number;
invoice_number: string;
customer_id: number | null;
customer_name: string;
customer?: {
company_id?: string;
vat_id?: string;
};
status: string;
issue_date: string;
due_date: string;
taxable_date?: string;
tax_date?: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
exchange_rate: string;
notes?: string;
payment_method?: string;
variable_symbol?: string;
constant_symbol?: string;
issued_by?: string | null;
paid_date?: string;
billing_text?: string;
bank_name?: string;
bank_swift?: string;
bank_iban?: string;
bank_account?: string;
bank_account_id?: number | null;
items?: InvoiceItem[];
subtotal: number;
vat_amount: number;
total: number;
order?: {
id: number;
order_number: string;
status?: string;
} | null;
order_id?: number;
order_number?: string;
has_pdf?: boolean;
valid_transitions?: string[];
}
export interface ReceivedInvoice {
id: number;
supplier_name: string;
invoice_number: string;
amount: number;
currency: string;
vat_rate: number;
issue_date: string;
due_date: string;
notes: string;
status: string;
file_name?: string;
created_at: string;
}
export interface ReceivedStats {
total_month: CurrencyAmount[];
total_month_czk: number | null;
vat_month: CurrencyAmount[];
vat_month_czk: number | null;
unpaid: CurrencyAmount[];
unpaid_czk: number | null;
unpaid_count: number;
month_count: number;
}
export const invoiceListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
month?: number;
year?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["invoices", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<Invoice>(
`/api/admin/invoices${qs ? `?${qs}` : ""}`,
);
},
});
export const receivedInvoiceListOptions = (filters: {
month?: number;
year?: number;
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"invoices",
"received",
{
month: filters.month,
year: filters.year,
search: filters.search,
sort: filters.sort,
order: filters.order,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery<ReceivedInvoice>(
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
);
},
});
export const invoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "stats", month, year],
queryFn: () =>
jsonQuery<InvoiceStats>(
`/api/admin/invoices/stats?month=${month}&year=${year}`,
),
});
export const receivedInvoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "received", "stats", month, year],
queryFn: () =>
jsonQuery<ReceivedStats>(
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
),
});
export const invoiceDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["invoices", id],
queryFn: () => jsonQuery<InvoiceDetail>(`/api/admin/invoices/${id}`),
enabled: !!id,
});

View File

@@ -0,0 +1,42 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface LeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export const leaveRequestsOptions = (mine = true) =>
queryOptions({
queryKey: ["leave-requests", mine ? "mine" : "all"],
queryFn: () =>
jsonQuery<LeaveRequest[]>(
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
),
});
export const leavePendingOptions = () =>
queryOptions({
queryKey: ["leave", "pending"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>(
"/api/admin/leave-requests?status=pending",
),
});
export const leaveProcessedOptions = () =>
queryOptions({
queryKey: ["leave", "processed"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>(
"/api/admin/leave-requests?status=approved,rejected",
),
});

View File

@@ -0,0 +1,113 @@
import {
useMutation,
useQueryClient,
type UseMutationOptions,
} from "@tanstack/react-query";
import apiFetch from "../../utils/api";
export interface ApiError extends Error {
status?: number;
}
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn;
}): Promise<TOut> {
const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
const method =
typeof opts.method === "function"
? opts.method(opts.body as TIn)
: opts.method;
const response = await apiFetch(url, {
method,
headers:
opts.body !== undefined
? { "Content-Type": "application/json" }
: undefined,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (response.status === 401) {
const err: ApiError = new Error("Unauthorized");
err.status = 401;
throw err;
}
let result: ApiResponseBody<TOut>;
try {
result = (await response.json()) as ApiResponseBody<TOut>;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
const err: ApiError = new Error(
result.error || `Request failed (${response.status})`,
);
err.status = response.status;
throw err;
}
return result.data as TOut;
}
export interface ApiMutationOptions<TIn, TOut> {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[];
}
/**
* Hook that wraps `useMutation` with `apiFetch` and broad-invalidation.
*
* Usage:
* const createCategory = useApiMutation<CategoryForm, WarehouseCategory>({
* url: "/api/admin/warehouse/categories",
* method: "POST",
* invalidate: ["warehouse"],
* });
* createCategory.mutate(form, {
* onSuccess: () => { ... },
* });
*
* The returned `data` from a successful mutation is `TOut`. Errors are thrown
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
*/
export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, onSuccess, ...rest } = opts;
const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }),
...rest,
onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) {
for (const key of invalidate) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
// Forward to user-provided onSuccess with the 4-arg signature expected by TanStack.
(
onSuccess as
| ((d: TOut, v: TIn, r: unknown, c: unknown) => void)
| undefined
)?.(data, variables, onMutateResult, context);
},
});
}

View File

@@ -0,0 +1,160 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface ItemTemplate {
id: number;
name: string;
description: string;
default_price: number;
category: string;
}
export interface ScopeSection {
_key: string;
title: string;
title_cz: string;
content: string;
}
export interface ScopeTemplate {
id: number;
name: string;
title?: string;
description?: string;
scope_template_sections?: ScopeSection[];
}
export interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
export interface Customer {
id: number;
name: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
quotation_count: number;
custom_fields?: CustomField[];
customer_field_order?: string[];
}
export const offerCustomersOptions = () =>
queryOptions({
queryKey: ["offer-customers"],
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
staleTime: 2 * 60_000,
});
export const itemTemplatesOptions = () =>
queryOptions({
queryKey: ["offer-templates", "items"],
queryFn: () =>
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
});
export const scopeTemplatesOptions = () =>
queryOptions({
queryKey: ["offer-templates", "scopes"],
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
});
export const offerListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
status?: string;
customer_id?: number;
}) =>
queryOptions({
queryKey: ["offers", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.customer_id)
params.set("customer_id", String(filters.customer_id));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
},
});
export interface OfferItemData {
id?: number;
description: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
position?: number;
}
export interface OfferSectionData {
title: string;
title_cz: string;
content: string;
position?: number;
}
export interface OfferLockInfo {
user_id: number;
username: string;
full_name: string;
}
export interface OfferOrderInfo {
id: number;
order_number: string;
status?: string;
}
export interface OfferDetailData {
id: number;
quotation_number: string;
project_code: string;
customer_id: number | null;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
items?: OfferItemData[];
sections?: OfferSectionData[];
status: string;
order: OfferOrderInfo | null;
locked_by: OfferLockInfo | null;
}
export const offerDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["offers", id],
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
enabled: !!id,
// 404s (deleted/missing offers) are not transient. Retrying just spams
// GETs and fires the detail-page redirect toast repeatedly.
retry: false,
});
export const offerNextNumberOptions = () =>
queryOptions({
queryKey: ["offers", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/offers/next-number",
),
});

View File

@@ -0,0 +1,84 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface OrderItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: number | boolean;
}
export interface OrderSection {
id?: number;
title: string;
title_cz?: string;
content: string;
}
export interface OrderInvoice {
id: number;
invoice_number: string;
}
export interface OrderProject {
id: number;
project_number: string;
name: string;
has_nas_folder?: boolean;
}
export interface OrderData {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
project_code?: string;
customer_name: string;
customer_order_number: string;
currency: string;
created_at: string;
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];
scope_title?: string;
scope_description?: string;
valid_transitions?: string[];
invoice?: OrderInvoice;
project?: OrderProject;
}
export const orderListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["orders", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
},
});
export const orderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["orders", id],
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
enabled: !!id,
});

View File

@@ -0,0 +1,111 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
import apiFetch from "../../utils/api";
export interface ProjectNote {
id: number;
content: string;
user_name: string;
created_at: string;
}
export interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
project_notes?: ProjectNote[];
}
export interface Project {
id: number;
project_number: string;
name: string;
}
export const projectListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["projects", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`,
);
},
});
export const projectDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["projects", id],
queryFn: () => jsonQuery<ProjectData>(`/api/admin/projects/${id}`),
enabled: !!id,
});
export interface ProjectFilesData {
items: Array<{
name: string;
type: "file" | "folder";
size?: number;
size_formatted?: string;
modified?: string;
extension?: string;
item_count?: number;
is_symlink?: boolean;
link_target?: string;
}>;
breadcrumb: string[];
path: string;
full_path: string;
}
export const projectFilesOptions = (projectId: number, path: string) =>
queryOptions({
queryKey: ["projects", String(projectId), "files", path],
queryFn: async (): Promise<ProjectFilesData> => {
const params = new URLSearchParams({ project_id: String(projectId) });
if (path) params.set("path", path);
let res: Response;
try {
res = await apiFetch(`/api/admin/project-files?${params}`);
} catch {
throw new Error("Chyba připojení");
}
if (res.status === 401) throw new Error("Unauthorized");
if (res.status === 404) {
return { items: [], breadcrumb: [""], path: "", full_path: "" };
}
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || `Request failed (${res.status})`);
}
return {
items: data.data.items || [],
breadcrumb: data.data.breadcrumb || [""],
path: data.data.path || "",
full_path: data.data.full_path || "",
};
},
});

View File

@@ -0,0 +1,87 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface CompanySettingsCustomField {
name: string;
value: string;
showLabel: boolean;
}
export interface CompanySettingsData {
// Company info
company_name?: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
ico?: string;
dic?: string;
has_logo?: boolean;
has_logo_dark?: boolean;
custom_fields?: CompanySettingsCustomField[];
supplier_field_order?: string[];
// System settings
require_2fa?: boolean;
default_currency?: string;
default_vat_rate?: number;
available_currencies?: string[];
available_vat_rates?: number[];
break_threshold_hours?: number;
break_duration_short?: number;
break_duration_long?: number;
clock_rounding_minutes?: number;
invoice_alert_email?: string;
leave_notify_email?: string;
smtp_from?: string;
smtp_from_name?: string;
max_login_attempts?: number;
lockout_minutes?: number;
max_requests_per_minute?: number;
// Numbering
quotation_prefix?: string;
order_type_code?: string;
invoice_type_code?: string;
offer_number_pattern?: string;
order_number_pattern?: string;
invoice_number_pattern?: string;
warehouse_receipt_prefix?: string;
warehouse_receipt_number_pattern?: string;
warehouse_issue_prefix?: string;
warehouse_issue_number_pattern?: string;
warehouse_inventory_prefix?: string;
warehouse_inventory_number_pattern?: string;
// Misc
app_version?: string;
}
export const companySettingsOptions = () =>
queryOptions({
queryKey: ["company-settings"],
queryFn: () =>
jsonQuery<CompanySettingsData>("/api/admin/company-settings"),
staleTime: 5 * 60_000,
});
export const systemInfoOptions = () =>
queryOptions({
queryKey: ["settings", "system-info"],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
"/api/admin/company-settings/system-info",
),
});
/** @deprecated Use systemInfoOptions instead — this query fetches system-info, not system settings. */
export const systemSettingsOptions = systemInfoOptions;
export const require2FAOptions = () =>
queryOptions({
queryKey: ["settings", "2fa"],
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});

View File

@@ -0,0 +1,104 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface TripVehicle {
id: number;
spz: string;
name: string;
}
export interface TripUser {
id: number;
name: string;
}
export interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
export const tripListOptions = (filters: {
month?: number;
year?: number;
vehicleId?: number;
userId?: number;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"list",
{
month: filters.month,
year: filters.year,
vehicleId: filters.vehicleId,
userId: filters.userId,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});
export const tripVehiclesOptions = () =>
queryOptions({
queryKey: ["trips", "vehicles"],
queryFn: () => jsonQuery<TripVehicle[]>("/api/admin/vehicles"),
staleTime: 2 * 60_000,
});
export const tripUsersOptions = () =>
queryOptions({
queryKey: ["trips", "users"],
queryFn: () => jsonQuery<TripUser[]>("/api/admin/trips/users"),
staleTime: 2 * 60_000,
});
export const tripHistoryOptions = (filters: {
month?: string;
vehicleId?: number;
userId?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"history",
{
month: filters.month,
vehicleId: filters.vehicleId,
userId: filters.userId,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", filters.month);
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -0,0 +1,37 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
export interface Role {
id: number;
name: string;
display_name: string;
}
export const userListOptions = (permission?: string) =>
queryOptions({
queryKey: ["users", { permission }],
queryFn: () => {
const url = permission
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<User[]>(url);
},
});
export const roleListOptions = () =>
queryOptions({
queryKey: ["roles"],
queryFn: () => jsonQuery<Role[]>("/api/admin/roles"),
staleTime: 2 * 60_000,
});

View File

@@ -0,0 +1,8 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const vehicleListOptions = () =>
queryOptions({
queryKey: ["vehicles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
});

View File

@@ -0,0 +1,430 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// --- Types ---
export interface WarehouseItem {
id: number;
item_number: string | null;
name: string;
description: string | null;
category_id: number | null;
unit: string;
min_quantity: number | null;
is_active: boolean;
notes: string | null;
category?: { id: number; name: string } | null;
total_quantity?: number;
available_quantity?: number;
stock_value?: number;
below_minimum?: boolean;
}
export interface WarehouseReceipt {
id: number;
receipt_number: string | null;
supplier_id: number | null;
delivery_note_number: string | null;
delivery_note_date: string | null;
received_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
supplier?: { id: number; name: string } | null;
received_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
_count?: { items: number };
items?: WarehouseReceiptItem[];
attachments?: WarehouseReceiptAttachment[];
}
export interface WarehouseReceiptItem {
id: number;
receipt_id: number;
item_id: number;
quantity: number;
unit_price: number;
location_id: number | null;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseReceiptAttachment {
id: number;
receipt_id: number;
file_name: string;
file_mime: string;
file_size: number;
file_path: string;
created_at: string;
}
export interface WarehouseIssue {
id: number;
issue_number: string | null;
project_id: number;
issued_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
project?: { id: number; name: string; project_number: string } | null;
issued_by_user?: { id: number; first_name: string; last_name: string } | null;
_count?: { items: number };
items?: WarehouseIssueItem[];
}
export interface WarehouseIssueItem {
id: number;
issue_id: number;
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
item?: WarehouseItem;
batch?: {
id: number;
quantity: number;
unit_price: number;
received_at: string;
} | null;
location?: { id: number; code: string; name: string } | null;
reservation?: { id: number; quantity: number; remaining_qty: number } | null;
}
export interface WarehouseReservation {
id: number;
item_id: number;
project_id: number;
quantity: number;
remaining_qty: number;
reserved_by: number | null;
notes: string | null;
status: "ACTIVE" | "FULFILLED" | "CANCELLED";
created_at: string;
modified_at: string | null;
item?: WarehouseItem;
project?: { id: number; name: string } | null;
reserved_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
}
export interface WarehouseInventorySession {
id: number;
session_number: string | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED";
created_at: string;
modified_at: string | null;
items?: WarehouseInventoryItem[];
}
export interface WarehouseInventoryItem {
id: number;
session_id: number;
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseCategory {
id: number;
name: string;
description: string | null;
sort_order: number;
}
export interface WarehouseLocation {
id: number;
code: string;
name: string;
description: string | null;
is_active: boolean;
}
export interface WarehouseSupplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
contact_person: string | null;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
is_active: boolean;
}
export interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
// --- Query Options ---
export const warehouseItemListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
sort?: string;
order?: string;
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "items", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return paginatedJsonQuery<WarehouseItem>(
`/api/admin/warehouse/items${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseItemDetailOptions = (
id: string | undefined,
enabled?: boolean,
) =>
queryOptions({
queryKey: ["warehouse", "items", id],
queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
enabled: enabled ?? !!id,
});
export const warehouseCategoryListOptions = () =>
queryOptions({
queryKey: ["warehouse", "categories"],
queryFn: () =>
jsonQuery<WarehouseCategory[]>("/api/admin/warehouse/categories"),
});
export const warehouseSupplierListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "suppliers", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery<WarehouseSupplier>(
`/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseLocationListOptions = (filters?: {
active?: "true" | "false" | "all";
}) =>
queryOptions({
queryKey: ["warehouse", "locations", filters ?? { active: "true" }],
queryFn: () => {
const active = filters?.active ?? "true";
return jsonQuery<WarehouseLocation[]>(
`/api/admin/warehouse/locations?active=${active}`,
);
},
});
export const warehouseReceiptListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "receipts", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReceipt>(
`/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseReceiptDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "receipts", id],
queryFn: () =>
jsonQuery<WarehouseReceipt>(`/api/admin/warehouse/receipts/${id}`),
enabled: !!id,
});
export const warehouseIssueListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "issues", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.project_id)
params.set("project_id", String(filters.project_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseIssue>(
`/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseIssueDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "issues", id],
queryFn: () =>
jsonQuery<WarehouseIssue>(`/api/admin/warehouse/issues/${id}`),
enabled: !!id,
});
export const warehouseReservationListOptions = (filters: {
page?: number;
perPage?: number;
item_id?: number;
project_id?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "reservations", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.item_id) params.set("item_id", String(filters.item_id));
if (filters.project_id)
params.set("project_id", String(filters.project_id));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReservation>(
`/api/admin/warehouse/reservations${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "inventory", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "inventory", id],
queryFn: () =>
jsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions/${id}`,
),
enabled: !!id,
});
export const warehouseStockStatusOptions = (filters?: {
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "stock-status", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return jsonQuery<WarehouseItem[]>(
`/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseBelowMinimumOptions = () =>
queryOptions({
queryKey: ["warehouse", "below-minimum"],
queryFn: () =>
jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
});
export const warehouseMovementLogOptions = (filters?: {
limit?: number;
type?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "movement-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.type) params.set("type", filters.type);
if (filters?.project_id)
params.set("project_id", String(filters.project_id));
if (filters?.date_from) params.set("date_from", filters.date_from);
if (filters?.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return jsonQuery<MovementLogRow[]>(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -0,0 +1,13 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: 1,
refetchOnReconnect: true,
},
},
});

View File

@@ -46,33 +46,6 @@
min-height: 32px;
}
/* Template dropdown menu */
.offers-template-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 200px;
max-height: 250px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 0.25rem;
}
.offers-template-menu-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition);
}
.offers-template-menu-item:hover {
background: var(--bg-secondary);
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
@@ -491,10 +464,69 @@
display: none;
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) {
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
}
}
@@ -542,6 +574,14 @@
color: var(--text-muted) !important;
}
/* Completed offer */
.offers-completed-row {
background: var(
--row-completed,
color-mix(in srgb, var(--success) 8%, transparent)
);
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {
@@ -550,13 +590,32 @@
}
/* Offer draft indicator */
.offers-draft-indicator {
/* Filters */
.admin-filters {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
margin-top: 0.2rem;
opacity: 0.8;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
@@ -14,6 +15,9 @@ import {
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural } from "../utils/formatters";
const API_BASE = "/api/admin";
@@ -61,7 +65,6 @@ interface MonthlyFund {
leave_hours: number;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
}
@@ -75,12 +78,6 @@ interface AttendanceData {
active_project_id: number | null;
}
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
@@ -92,22 +89,54 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [data, setData] = useState<AttendanceData>({
ongoing_shift: null,
today_shifts: [],
date: "",
leave_balance: {
vacation_total: 160,
vacation_used: 0,
vacation_remaining: 160,
sick_used: 0,
},
monthly_fund: null,
project_logs: [],
active_project_id: null,
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
@@ -117,10 +146,7 @@ export default function Attendance() {
});
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [switchingProject, setSwitchingProject] = useState(false);
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean;
action: string | null;
@@ -139,45 +165,12 @@ export default function Attendance() {
};
}, []);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/status`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setData(result.data);
setNotes(result.data.ongoing_shift?.notes || "");
setProjectLogs(result.data.project_logs || []);
setActiveProjectId(result.data.active_project_id || null);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
setNotes(statusQuery.data.ongoing_shift?.notes || "");
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success) {
const items = Array.isArray(result.data) ? result.data : [];
setProjects(items);
}
} catch {
// silent - projects are supplementary
}
};
loadProjects();
}, []);
}, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
@@ -266,51 +259,29 @@ export default function Attendance() {
gpsData: Record<string, unknown> = {},
) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false);
if (result.success) {
await fetchData();
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
punchTimeoutRef.current = setTimeout(() => {
alert.success(result?.message || "Uloženo");
}, 300);
} catch (e) {
setSubmitting(false);
alert.error("Chyba připojení");
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
const result = await punchMutation.mutateAsync({
punch_action: "break_start",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await fetchData();
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -318,45 +289,22 @@ export default function Attendance() {
const handleSaveNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await notesMutation.mutateAsync({ notes });
alert.success("Poznámka byla uložena");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
const result = await switchProjectMutation.mutateAsync({
project_id: newProjectId ? Number(newProjectId) : null,
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await fetchData();
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Projekt přepnut");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSwitchingProject(false);
}
@@ -380,142 +328,43 @@ export default function Attendance() {
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(leaveForm),
const result = await leaveRequestMutation.mutateAsync(leaveForm);
setIsLeaveModalOpen(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (loading) {
if (statusQuery.isPending) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "120px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "180px" }}
/>
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (!statusQuery.data) {
return null;
}
const {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = data;
} = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
@@ -774,7 +623,13 @@ export default function Attendance() {
{formatTime(shift.departure_time)}
</td>
<td className="admin-mono">
{formatMinutes(calculateWorkMinutes(shift), true)}
{formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
)}
</td>
{projects.length > 0 && (
<td>
@@ -844,7 +699,7 @@ export default function Attendance() {
{vacationDaysRemaining}
</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
@@ -934,8 +789,6 @@ export default function Attendance() {
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
@@ -1020,7 +873,7 @@ export default function Attendance() {
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg
width="20"
@@ -1172,15 +1025,15 @@ export default function Attendance() {
leaveForm.date_to,
)}
</strong>{" "}
{(() => {
const d = calculateBusinessDays(
{czechPlural(
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
);
if (d === 1) return "pracovní den";
if (d >= 2 && d <= 4) return "pracovní dny";
return "pracovních dnů";
})()}
),
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
</span>
<span className="text-muted">
{calculateBusinessDays(

View File

@@ -10,7 +10,7 @@ import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string;
@@ -18,20 +18,34 @@ interface UserTotalData {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
fund: number | null;
worked_hours: number;
covered: number;
missing: number;
overtime: number;
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
worked_holiday_hours?: number;
}
function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
// fondUsed mirrors the Fond "used" line in the IIFE below:
// real_worked + vacation + worked_holiday + free_holiday
// (delta is fondUsed - fund; 0 means exactly at the fund).
const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
@@ -85,9 +99,9 @@ export default function AttendanceAdmin() {
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
// Show spinner only on initial load (no data yet), not on filter changes
const isInitialLoad =
loading &&
data.records.length === 0 &&
@@ -95,83 +109,8 @@ export default function AttendanceAdmin() {
if (isInitialLoad) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div className="admin-skeleton-row">
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{[0, 1, 2].map((i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
<div className="admin-skeleton-line w-1/2" />
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "4px" }}
/>
</div>
</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -281,8 +220,15 @@ export default function AttendanceAdmin() {
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
@@ -298,9 +244,11 @@ export default function AttendanceAdmin() {
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
gap: "0.4rem",
minHeight: "2rem",
}}
>
{ut.vacation_hours > 0 && (
@@ -313,9 +261,9 @@ export default function AttendanceAdmin() {
Nem: {ut.sick_hours}h
</span>
)}
{ut.holiday_hours > 0 && (
{ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
Sv: {Math.round(ut.svatek)}h
</span>
)}
{ut.unpaid_hours > 0 && (
@@ -324,62 +272,93 @@ export default function AttendanceAdmin() {
</span>
)}
</div>
{ut.fund !== null && (
<div className="mt-2">
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {ut.worked_hours}h / {ut.fund}h
</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
// (everything the user actually got paid for this month:
// raw worked time with sub-day remainders, plus
// vacation days, plus hours worked on holidays, plus
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
return (
<div
className="kpi-card-footer"
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
/>
</div>
</div>
)}
{data.leave_balances[uid] && (
<div
className="text-secondary"
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
/ {data.leave_balances[uid].vacation_total}h
</div>
)}
>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div>
</div>
);
@@ -443,7 +422,14 @@ export default function AttendanceAdmin() {
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord}
editingRecord={
editingRecord
? {
user_name: editingRecord.user_name ?? "",
shift_date: editingRecord.shift_date,
}
: null
}
/>
<ConfirmModal

View File

@@ -1,79 +1,29 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useQuery } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} from "../lib/queries/attendance";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface UserShort {
id: number | string;
name: string;
}
interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
interface ProjectData {
months: Record<string, MonthProjectData>;
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
@@ -134,23 +84,16 @@ const getProgressBackground = (
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<BalancesData>({
users: [],
balances: {},
});
const [fundLoading, setFundLoading] = useState(true);
const [fundData, setFundData] = useState<FundData>({
months: {},
holidays: [],
users: [],
balances: {},
});
const [projectLoading, setProjectLoading] = useState(true);
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{
@@ -169,71 +112,37 @@ export default function AttendanceBalances() {
userName: string;
}>({ show: false, userId: null, userName: "" });
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances&year=${year}`,
);
const result = await response.json();
if (result.success) {
setData(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[year, alert],
);
const fetchFundData = useCallback(async () => {
setFundLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=workfund&year=${year}`,
);
const result = await response.json();
if (result.success) {
setFundData(result.data);
}
} catch {
// silent - fund data is supplementary
} finally {
setFundLoading(false);
}
}, [year]);
const fetchProjectData = useCallback(async () => {
setProjectLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=project_report&year=${year}`,
);
const result = await response.json();
if (result.success) {
setProjectData(result.data);
}
} catch {
// silent - project data is supplementary
} finally {
setProjectLoading(false);
}
}, [year]);
useEffect(() => {
const loadAll = async () => {
await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]);
};
loadAll();
}, [fetchData, fetchFundData, fetchProjectData]);
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
@@ -247,63 +156,29 @@ export default function AttendanceBalances() {
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
const result = await editMutation.mutateAsync({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
});
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -315,7 +190,7 @@ export default function AttendanceBalances() {
}
const getYearFundTotals = (userId: string) => {
if (!fundData.months || Object.keys(fundData.months).length === 0)
if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
@@ -380,132 +255,137 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && Object.keys(data.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{!loading && Object.keys(data.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Pokryto</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(data.balances).map(([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
<tr key={userId}>
<td className="fw-500">{balance.name}</td>
<td className="admin-mono">{balance.vacation_total}</td>
<td className="admin-mono">
{balance.vacation_used.toFixed(1)}
</td>
<td className="admin-mono">
<span
className={getVacationClass(
balance.vacation_remaining,
)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">
{balance.sick_used.toFixed(1)}
</td>
<td className="admin-mono">
{yf ? `${yf.fund}h` : "—"}
</td>
<td className="admin-mono">
{yf ? `${yf.covered}h` : "—"}
</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : "—"}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(userId, balance)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
<button
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{balancesPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{balancesData &&
Object.keys(balancesData.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Pokryto</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(balancesData.balances).map(
([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
<tr key={userId}>
<td className="fw-500">{balance.name}</td>
<td className="admin-mono">
{balance.vacation_total}
</td>
<td className="admin-mono">
{balance.vacation_used.toFixed(1)}
</td>
<td className="admin-mono">
<span
className={getVacationClass(
balance.vacation_remaining,
)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">
{balance.sick_used.toFixed(1)}
</td>
<td className="admin-mono">
{yf ? `${yf.fund}h` : "—"}
</td>
<td className="admin-mono">
{yf ? `${yf.covered}h` : "—"}
</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : "—"}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() =>
openEditModal(userId, balance)
}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
<button
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
</div>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundLoading &&
fundData.months &&
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -587,7 +467,7 @@ export default function AttendanceBalances() {
gap: "0.375rem",
}}
>
{fundData.users &&
{fundData?.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
@@ -668,23 +548,15 @@ export default function AttendanceBalances() {
</motion.div>
)}
{fundLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
{fundPending && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Monthly Project Overview */}
{!projectLoading &&
projectData.months &&
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -876,123 +748,83 @@ export default function AttendanceBalances() {
</motion.div>
)}
{projectLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
{projectPending && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingUser && (
<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={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal"
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 }}
<FormModal
isOpen={showEditModal && !!editingUser}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit dovolenou"
loading={editMutation.isPending}
>
{editingUser && (
<>
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit dovolenou</h2>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
{editingUser.name}
</p>
</div>
{editingUser.name}
</p>
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</>
)}
</AnimatePresence>
</FormModal>
{/* Reset Confirmation */}
<ConfirmModal
@@ -1005,6 +837,7 @@ export default function AttendanceBalances() {
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
loading={resetMutation.isPending}
/>
</div>
);

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -7,14 +9,9 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface User {
id: number | string;
name: string;
}
interface CreateForm {
user_id: string;
shift_date: string;
@@ -35,9 +32,18 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData ?? []).map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}));
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
@@ -58,26 +64,6 @@ export default function AttendanceCreate() {
};
});
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json();
if (result.success) {
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false);
}
};
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -89,22 +75,11 @@ export default function AttendanceCreate() {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
alert.success(result.message);
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
const result = await createMutation.mutateAsync(form);
alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -123,34 +98,12 @@ export default function AttendanceCreate() {
const isWorkType = form.leave_type === "work";
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i}>
<div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -224,7 +177,6 @@ export default function AttendanceCreate() {
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>

View File

@@ -1,9 +1,15 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
@@ -14,35 +20,12 @@ import {
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLog[];
}
const MONTH_NAMES = [
"Leden",
@@ -193,48 +176,19 @@ const renderProjectCell = (record: AttendanceRecord) => {
};
export default function AttendanceHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRecords(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanyName(d.data.company_name || "");
})
.catch(() => {});
}, []);
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = data ?? [];
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");
@@ -244,7 +198,6 @@ export default function AttendanceHistory() {
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
@@ -256,26 +209,51 @@ export default function AttendanceHistory() {
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;
else if (leaveType === "unpaid") unpaidHours += hours;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-computed from Czech public holidays by the print/KPI code.
// Old records may still exist; skip so they don't double-count.
}
}
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const businessDays = getBusinessDaysInMonth(yr, mo);
const fund = businessDays * 8;
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
const fund = (businessDays + holidayCount) * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
// Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100;
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
// (the all-in number: everything the user got paid for this month).
const realWorked = totalMinutes / 60;
const freeHolidayHours = calculateFreeHolidayHours(
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
holidayDates,
);
const workedHolidayHours = records
.filter(
(r) =>
(r.leave_type || "work") === "work" &&
holidayDates.includes(normalizeDateStr(r.shift_date)),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
const fondUsed =
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
const delta = fondUsed - fund;
const missing = Math.max(0, Math.round(-delta * 100) / 100);
const overtime = Math.max(0, Math.round(delta * 100) / 100);
const remaining = missing;
const monthlyFund = {
fund,
business_days: businessDays,
worked,
holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
covered,
remaining,
overtime,
@@ -286,9 +264,12 @@ export default function AttendanceHistory() {
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
};
}, [records, month]);
@@ -372,7 +353,6 @@ export default function AttendanceHistory() {
}
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
@media print {
@@ -459,143 +439,161 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line"
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "6px", borderRadius: "3px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px", marginTop: "0.5rem" }}
/>
</div>
</div>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{!loading && computed.monthlyFund && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
) : (
<>
{computed.monthlyFund && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{computed.totalMinutes > 0 && (
<span
className="attendance-leave-badge"
title="Odpracováno (reálně)"
>
Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
</div>
</div>
</div>
)}
{!computed.monthlyFund && (
<div
className="text-muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
<span>
{"Pokryto: "}
{computed.monthlyFund.covered}h (práce{" "}
{computed.monthlyFund.worked}h
{computed.vacationHours > 0 &&
` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 &&
` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 &&
` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 &&
` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
Fond měsíce není k dispozici
</div>
</div>
</div>
)}
{!loading && !computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
</>
)}
</div>
</motion.div>
@@ -608,90 +606,89 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
))}
</div>
)}
{!loading && records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
)}
{records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
);
})}
</tbody>
</table>
</div>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
@@ -740,9 +737,7 @@ export default function AttendanceHistory() {
</span>
</div>
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
@@ -758,13 +753,6 @@ export default function AttendanceHistory() {
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -9,65 +10,33 @@ 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";
interface LocationRecord {
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
export default function AttendanceLocation() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [record, setRecord] = useState<LocationRecord | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (result.success) {
const raw = result.data.record || result.data;
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
} finally {
setLoading(false);
}
};
const locationQuery = useQuery(attendanceLocationOptions(id));
const record = locationQuery.data ?? null;
const isPending = locationQuery.isPending;
fetchData();
}, [id, alert, navigate]);
// Navigate away on fetch error
useEffect(() => {
if (locationQuery.error) {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
}
}, [locationQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!record || loading) return;
if (!record || isPending) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
@@ -175,7 +144,7 @@ export default function AttendanceLocation() {
mapInstanceRef.current = null;
}
};
}, [record, loading]);
}, [record, isPending]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
@@ -183,57 +152,7 @@ export default function AttendanceLocation() {
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
>
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.25rem",
}}
>
{[0, 1].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "50%" }}
/>
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
);
}
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (!record) {
return null;
@@ -247,6 +166,14 @@ export default function AttendanceLocation() {
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<motion.div

View File

@@ -1,13 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import FormModal from "../components/FormModal";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -77,13 +80,6 @@ interface AuditLogEntry {
user_ip: string | null;
}
interface PaginationData {
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface Filters {
search: string;
action: string;
@@ -95,9 +91,6 @@ interface Filters {
export default function AuditLog() {
const { hasPermission } = useAuth();
const alert = useAlert();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const [filters, setFilters] = useState<Filters>({
search: "",
action: "",
@@ -105,90 +98,97 @@ export default function AuditLog() {
date_from: "",
date_to: "",
});
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false);
const fetchLogs = useCallback(
async (page = 1, perPage = 50) => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
const { data: logsData, isPending } = useQuery({
queryKey: [
"audit-log",
{
search: filters.search,
action: filters.action,
entityType: filters.entity_type,
dateFrom: filters.date_from,
dateTo: filters.date_to,
page,
perPage,
},
],
queryFn: async () => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`,
);
const data = await response.json();
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : []);
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
});
} else {
alert.error(data.error || "Nepodařilo se načíst audit log");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`,
);
if (response.status === 401) throw new Error("Unauthorized");
const result = await response.json();
if (!result.success)
throw new Error(result.error || "Nepodařilo se načíst audit log");
return {
data: Array.isArray(result.data) ? result.data : [],
pagination: {
total: result.pagination?.total ?? 0,
page: result.pagination?.page ?? 1,
per_page: result.pagination?.limit ?? perPage,
total_pages: result.pagination?.total_pages ?? 1,
},
};
},
[filters, alert],
);
});
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const logs: AuditLogEntry[] = logsData?.data ?? [];
const pagination = logsData?.pagination ?? null;
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const cleanupMutation = useApiMutation<
{ days: number; confirm?: string },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/audit-log/cleanup`,
method: () => "POST",
invalidate: ["audit-log"],
onSuccess: (data) => {
alert.success(data?.message || "Hotovo");
setShowCleanup(false);
},
});
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);
};
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50);
setPage(newPage);
};
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage);
setPage(1);
setPerPage(newPerPage);
};
const handleCleanup = async () => {
setCleaning(true);
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days: cleanupDays }),
// Backend requires this literal confirmation token when wiping everything.
await cleanupMutation.mutateAsync({
days: cleanupDays,
...(cleanupDays === 0 ? { confirm: "DELETE_ALL_AUDIT" } : {}),
});
const data = await response.json();
if (data.success) {
alert.success(data.message);
setShowCleanup(false);
fetchLogs();
} else {
alert.error(data.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCleaning(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -197,65 +197,10 @@ export default function AuditLog() {
return new Date(dateString).toLocaleString("cs-CZ");
};
if (loading && logs.length === 0) {
if (isPending && logs.length === 0) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "160px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "100px" }} />
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "4px" }}
/>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "120px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "70px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line flex-1" />
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -296,78 +241,36 @@ export default function AuditLog() {
</button>
</motion.div>
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<div
className="admin-modal-backdrop"
onClick={() => !cleaning && setShowCleanup(false)}
/>
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showCleanup}
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
onSubmit={handleCleanup}
title="Vyčistit audit log"
submitLabel="Smazat"
loading={cleanupMutation.isPending}
>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-danger">
<svg
width="24"
height="24"
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>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">
Smazat záznamy starší než:
</p>
<div style={{ margin: "0.75rem auto", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCleanup(false)}
className="admin-btn admin-btn-secondary"
disabled={cleaning}
>
Zrušit
</button>
<button
type="button"
onClick={handleCleanup}
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? "Mažu..." : "Smazat"}
</button>
</div>
</motion.div>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
)}
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</FormModal>
<motion.div
className="admin-card mb-4"
@@ -442,112 +345,73 @@ export default function AuditLog() {
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading &&
Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td>
<div
className="admin-skeleton-line"
style={{ width: "110px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{
width: "70px",
height: "22px",
borderRadius: "10px",
}}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "60%", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "90px", height: "14px" }}
/>
</td>
</tr>
))}
{!loading && logs.length === 0 && (
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<table className="admin-table">
<thead>
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
)}
{!loading &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
)}
{logs.length > 0 &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<Pagination

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
@@ -69,13 +73,26 @@ export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false);
const queryClient = useQueryClient();
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "dashboard"],
});
// 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpLoading, setTotpLoading] = useState(true);
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
@@ -88,46 +105,6 @@ export default function Dashboard() {
useModalLock(show2FASetup);
useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false);
}
}, []);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false);
}
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
@@ -135,20 +112,13 @@ export default function Dashboard() {
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard();
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error("Chyba pripojeni");
alert.success(result?.message || "Docházka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
} finally {
setPunching(false);
}
@@ -167,7 +137,7 @@ export default function Dashboard() {
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
}, [dashData, alert, fetchDashboard]);
}, [dashData, alert, punchMutation]);
// 2FA handlery
const handleStart2FASetup = async () => {
@@ -202,7 +172,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
setTotpEnabled(true);
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
@@ -230,7 +202,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
setTotpEnabled(false);
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
@@ -335,63 +309,10 @@ export default function Dashboard() {
</motion.div>
)}
{/* Skeleton loading */}
{/* Loading spinner */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line h-24"
style={{ borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-main-grid">
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
</div>
</div>
<div className="dash-bottom">
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
@@ -400,12 +321,14 @@ export default function Dashboard() {
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
hasPermission("orders.view")) && (
<DashKpiCards dashData={dashData ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
dashData={dashData}
dashData={dashData ?? null}
punching={punching}
onPunch={handleQuickPunch}
/>
@@ -423,7 +346,7 @@ export default function Dashboard() {
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
)}
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}

View File

@@ -5,7 +5,9 @@ import {
useParams,
Link,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -35,6 +37,17 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import {
invoiceDetailOptions,
type InvoiceDetail,
} from "../lib/queries/invoices";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters";
const API_BASE = "/api/admin";
@@ -60,29 +73,13 @@ interface InvoiceItem {
id?: number;
_key: string;
description: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;
vat_rate: number;
}
interface Customer {
id: number;
name: string;
company_id?: string;
city?: string;
}
interface BankAccount {
id: number;
account_name: string;
account_number?: string;
bank_name?: string;
bic?: string;
iban?: string;
is_default?: boolean;
}
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -106,42 +103,6 @@ interface InvoiceForm {
bank_account: string;
}
interface InvoiceCustomer {
company_id?: string;
vat_id?: string;
}
interface Invoice {
id: number;
invoice_number: string;
customer_id?: number | null;
customer_name: string | null;
customer?: InvoiceCustomer;
order_id?: number;
order_number?: string;
currency: string;
status: string;
issue_date: string;
due_date: string;
tax_date: string;
payment_method: string;
constant_symbol?: string;
issued_by: string | null;
paid_date?: string;
notes: string;
language: string;
apply_vat: number | string;
vat_rate?: number;
billing_text?: string;
bank_name?: string;
bank_swift?: string;
bank_iban?: string;
bank_account?: string;
bank_account_id?: number | null;
items: Omit<InvoiceItem, "_key">[];
valid_transitions?: string[];
}
// Sortable row for create mode
function SortableInvoiceRow({
item,
@@ -205,20 +166,34 @@ function SortableInvoiceRow({
</button>
</td>
<td className="text-tertiary text-center fw-500">{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
className="admin-form-input fw-500"
placeholder="Popis položky..."
/>
<td style={{ verticalAlign: "top" }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
>
<input
type="text"
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
className="admin-form-input fw-500"
placeholder="Popis položky..."
/>
<input
type="text"
value={item.item_description}
onChange={(e) =>
onUpdate(index, "item_description", e.target.value)
}
className="admin-form-input"
placeholder="Podrobný popis (volitelný)"
style={{ fontSize: "0.8rem", opacity: 0.8 }}
/>
</div>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))}
className="admin-form-input"
min="0"
step="any"
@@ -247,7 +222,9 @@ function SortableInvoiceRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onChange={(e) =>
onUpdate(index, "unit_price", Number(e.target.value))
}
className="admin-form-input"
step="any"
style={{
@@ -318,6 +295,7 @@ export default function InvoiceDetail() {
(): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
@@ -367,12 +345,12 @@ export default function InvoiceDetail() {
bank_account: "",
});
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([
{
_key: "inv-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
@@ -381,44 +359,30 @@ export default function InvoiceDetail() {
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [dataReady, setDataReady] = useState(false);
const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} | null>(null);
const companySettings = useQuery(companySettingsOptions()).data;
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(() => {});
}, []);
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const vatOptions = (
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
@@ -437,8 +401,36 @@ export default function InvoiceDetail() {
}
}, []);
// ─── TanStack Query ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? [];
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
`${API_BASE}/invoices/next-number`,
).then((d) => d?.next_number || d?.number || ""),
enabled: !isEdit,
});
const orderDataQuery = useQuery({
queryKey: ["invoices", "order-data", fromOrderId],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`${API_BASE}/invoices/order-data/${fromOrderId}`,
),
enabled: !!fromOrderId,
});
// ─── Edit mode state ───
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [notes, setNotes] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
@@ -456,234 +448,191 @@ export default function InvoiceDetail() {
};
}, []);
// ─── Data loading ───
// ─── Sync query data to form state ───
// Edit mode: populate form from invoice data
useEffect(() => {
if (isEdit) return;
const load = async () => {
try {
const promises = [
apiFetch(`${API_BASE}/invoices/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/bank-accounts`),
];
if (fromOrderId) {
promises.push(
apiFetch(`${API_BASE}/invoices/order-data/${fromOrderId}`),
);
}
if (!isEdit || dataReady) return;
if (
invoiceQuery.isLoading ||
bankAccountsQuery.isLoading ||
customersQuery.isLoading
)
return;
if (!invoiceQuery.data) return;
const results = await Promise.all(promises);
const inv = invoiceQuery.data;
const numRes = results[0];
if (numRes.ok) {
const numData = await numRes.json();
if (numData.success)
setInvoiceNumber(
numData.data?.next_number || numData.data?.number || "",
);
}
// Match bank account from invoice's bank details
let matchedBankId: number | string = "";
const bankData = bankAccountsQuery.data ?? [];
const match = bankData.find(
(b) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
const custRes = results[1];
if (custRes.ok) {
const custData = await custRes.json();
if (custData.success)
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.customers || [],
);
}
const bankRes = results[2];
if (bankRes.ok) {
const bankData = await bankRes.json();
if (bankData.success && Array.isArray(bankData.data)) {
setBankAccounts(bankData.data);
const defaultAcc = bankData.data.find(
(a: BankAccount) => a.is_default,
);
if (defaultAcc) {
setForm((prev) => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || "",
bank_swift: defaultAcc.bic || "",
bank_iban: defaultAcc.iban || "",
bank_account: defaultAcc.account_number || "",
}));
}
}
}
// Pre-fill from order
if (fromOrderId && results[3]?.ok) {
const orderData = await results[3].json();
if (orderData.success) {
const order = orderData.data;
const vatRate =
Number(order.vat_rate) ||
(companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id,
customer_name: order.customer_name || "",
order_id: order.id,
currency:
order.currency || companySettings?.default_currency || "CZK",
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate,
}));
if (order.items?.length > 0) {
setItems(
order.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate,
})),
);
}
}
}
} catch {
alert.error("Chyba při načítání dat");
} finally {
setLoading(false);
}
const formData: InvoiceForm = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: inv.issue_date
? new Date(inv.issue_date).toISOString().split("T")[0]
: "",
due_date: inv.due_date
? new Date(inv.due_date).toISOString().split("T")[0]
: "",
tax_date: inv.tax_date
? new Date(inv.tax_date).toISOString().split("T")[0]
: "",
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
notes: inv.notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: inv.bank_name || "",
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
};
load();
}, [isEdit, fromOrderId, alert]);
setForm(formData);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Edit mode: load existing invoice
const fetchDetail = useCallback(async () => {
if (!id) return;
try {
const [response, custRes, bankRes] = await Promise.all([
apiFetch(`${API_BASE}/invoices/${id}`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/bank-accounts`),
]);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const inv = result.data;
setInvoice(inv);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Populate customers list
if (custRes.ok) {
const custData = await custRes.json();
if (custData.success)
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.customers || [],
);
}
// Populate bank accounts and match existing
let matchedBankId: number | string = "";
if (bankRes.ok) {
const bankData = await bankRes.json();
if (bankData.success && Array.isArray(bankData.data)) {
setBankAccounts(bankData.data);
// Match by IBAN or account number
const match = bankData.data.find(
(b: BankAccount) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
}
}
// Populate form state from existing invoice
const formData = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: inv.issue_date
? new Date(inv.issue_date).toISOString().split("T")[0]
: "",
due_date: inv.due_date
? new Date(inv.due_date).toISOString().split("T")[0]
: "",
tax_date: inv.tax_date
? new Date(inv.tax_date).toISOString().split("T")[0]
: "",
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
notes: inv.notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: inv.bank_name || "",
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
};
setForm(formData);
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date);
const due = new Date(inv.due_date);
const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays > 0 && diffDays <= 60) setDueDays(diffDays);
}
// Populate items from existing invoice
const mappedItems =
inv.items?.length > 0
? inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
setItems(mappedItems);
}
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
});
} else {
alert.error(result.error || "Nepodařilo se načíst fakturu");
navigate("/invoices");
}
} catch {
alert.error("Chyba připojení");
navigate("/invoices");
} finally {
setLoading(false);
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date);
const due = new Date(inv.due_date);
const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays > 0 && diffDays <= 60) setDueDays(diffDays);
}
}, [id, alert, navigate]);
// Populate items from existing invoice
const invItems = inv.items;
const mappedItems =
invItems && invItems.length > 0
? invItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id,
description: item.description || "",
item_description: item.item_description || "",
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
setItems(mappedItems);
}
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
});
setDataReady(true);
}, [
isEdit,
dataReady,
invoiceQuery.isLoading,
invoiceQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
]); // eslint-disable-line react-hooks/exhaustive-deps
// Create mode: populate form from query data
useEffect(() => {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
if (isEdit || dataReady) return;
if (
nextNumberQuery.isLoading ||
bankAccountsQuery.isLoading ||
customersQuery.isLoading
)
return;
if (fromOrderId && orderDataQuery.isLoading) return;
// Capture initial snapshot for dirty-checking once data finishes loading.
// Edit mode: captured inside fetchDetail from raw API data.
// Create mode: captured on first stable render after loading completes.
if (!loading && !initialSnapshotRef.current) {
// Set invoice number
if (nextNumberQuery.data) {
setInvoiceNumber(nextNumberQuery.data);
}
// Set default bank account
const defaultAcc = bankAccounts.find((a) => a.is_default);
if (defaultAcc) {
setForm((prev) => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || "",
bank_swift: defaultAcc.bic || "",
bank_iban: defaultAcc.iban || "",
bank_account: defaultAcc.account_number || "",
}));
}
// Pre-fill from order
if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data;
const vatRate =
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,
customer_name: (order.customer_name as string) || "",
order_id: order.id as number,
currency:
(order.currency as string) ||
companySettings?.default_currency ||
"CZK",
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate,
}));
const orderItems = order.items as Record<string, unknown>[] | undefined;
if (orderItems && orderItems.length > 0) {
setItems(
orderItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "",
item_description: (item.item_description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate,
})),
);
}
}
setDataReady(true);
}, [
isEdit,
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
fromOrderId,
orderDataQuery.isLoading,
orderDataQuery.data,
companySettings,
bankAccounts,
]); // eslint-disable-line react-hooks/exhaustive-deps
// Capture initial snapshot for dirty-checking once data sync completes.
// Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
}
@@ -814,6 +763,34 @@ export default function InvoiceDetail() {
}, [items, form.apply_vat]);
// ─── Create/Edit mode: submit ───
const saveMutation = useApiMutation<
Record<string, unknown>,
{ invoice_id: number }
>({
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["invoices", "orders"],
onSuccess: (data) => {
const invoiceId = isEdit ? Number(id) : data.invoice_id;
// PDF binary generation — KEEP as raw apiFetch
void apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
},
});
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "PUT",
invalidate: ["invoices", "orders"],
});
const invoiceDeleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "DELETE",
invalidate: ["invoices", "orders"],
});
const handleCreateSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -831,7 +808,7 @@ export default function InvoiceDetail() {
setSaving(true);
try {
const payload: any = {
const payload: Record<string, unknown> = {
...form,
due_date: computedDueDate || form.due_date,
items: items
@@ -843,43 +820,21 @@ export default function InvoiceDetail() {
};
if (isEdit) payload.invoice_number = invoiceNumber;
const url = isEdit
? `${API_BASE}/invoices/${id}`
: `${API_BASE}/invoices`;
const method = isEdit ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
if (!isEdit) clearDraft();
const invoiceId = isEdit ? id : result.data.invoice_id;
await apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
alert.success(
result.message ||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
fetchDetail();
} else {
navigate(`/invoices/${result.data.invoice_id}`);
}
} else {
alert.error(
result.error ||
(isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu"),
);
const data = await saveMutation.mutateAsync(payload);
if (!isEdit) clearDraft();
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items });
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(
e instanceof Error
? e.message
: isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu",
);
} finally {
setSaving(false);
}
@@ -888,23 +843,14 @@ export default function InvoiceDetail() {
// ─── Edit mode: status change ───
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
setStatusChanging(statusConfirm.status);
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: statusConfirm.status }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
fetchDetail();
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
@@ -938,18 +884,11 @@ export default function InvoiceDetail() {
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Faktura byla smazána");
navigate("/invoices");
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
} catch {
alert.error("Chyba připojení");
await invoiceDeleteMutation.mutateAsync(undefined);
alert.success("Faktura byla smazána");
navigate("/invoices");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
@@ -960,53 +899,6 @@ export default function InvoiceDetail() {
if (!isEdit && !hasPermission("invoices.create")) return <Forbidden />;
if (isEdit && !hasPermission("invoices.view")) return <Forbidden />;
// ─── Loading skeleton ───
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
{isEdit && (
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
)}
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
{isEdit && (
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
)}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════
// PAID INVOICE — read-only view
// ═══════════════════════════════════════════════════════════
@@ -1015,6 +907,13 @@ export default function InvoiceDetail() {
if (isEdit && !invoice) return null;
if (isPaid && invoice) {
if (!dataReady) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
{/* Header */}
@@ -1183,7 +1082,7 @@ export default function InvoiceDetail() {
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
</div>
{invoice.items?.length > 0 ? (
{invoice.items && invoice.items.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
@@ -1224,6 +1123,17 @@ export default function InvoiceDetail() {
</td>
<td className="fw-500">
{item.description || "\u2014"}
{item.item_description && (
<div
style={{
fontSize: "0.8rem",
opacity: 0.8,
marginTop: 2,
}}
>
{item.item_description}
</div>
)}
</td>
<td className="text-center">
{item.quantity}{" "}
@@ -1319,6 +1229,13 @@ export default function InvoiceDetail() {
// ═══════════════════════════════════════════════════════════
// CREATE MODE + EDIT (not paid) — shared form
// ═══════════════════════════════════════════════════════════
if (!dataReady) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<motion.div

View File

@@ -1,11 +1,5 @@
import {
useState,
useEffect,
useCallback,
useRef,
lazy,
Suspense,
} from "react";
import { useState, useEffect, useRef, lazy, Suspense } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useSearchParams } from "react-router-dom";
@@ -17,7 +11,14 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
invoiceListOptions,
invoiceStatsOptions,
type Invoice,
type InvoiceStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
@@ -39,11 +40,6 @@ const MONTH_NAMES = [
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
@@ -84,31 +80,6 @@ const STATUS_FILTERS = [
{ value: "overdue", label: "Po splatnosti" },
];
interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
interface DraftData {
form: Record<string, unknown>;
items: Record<string, unknown>[];
@@ -134,8 +105,6 @@ export default function Invoices() {
const now = new Date();
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear());
const [stats, setStats] = useState<InvoiceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
@@ -154,28 +123,15 @@ export default function Invoices() {
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
const fetchStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await apiFetch(
`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
}, [statsMonth, statsYear]);
const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear));
const stats = statsQuery.data ?? null;
useEffect(() => {
fetchStats();
}, [fetchStats]);
if (statsQuery.data) {
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const prevMonth = () => {
slideDirection.current = -1;
@@ -225,24 +181,23 @@ export default function Invoices() {
setDraft(null);
};
const queryClient = useQueryClient();
const {
items: invoices,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData<Invoice>("invoices", {
search,
sort,
order,
page,
extraParams: {
month: String(statsMonth),
year: String(statsYear),
...(statusFilter ? { status: statusFilter } : {}),
},
errorMsg: "Nepodařilo se načíst faktury",
});
isPending: initialLoad,
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
sort,
order,
page,
month: statsMonth,
year: statsYear,
status: statusFilter || undefined,
}),
);
if (!hasPermission("invoices.view")) return <Forbidden />;
@@ -260,8 +215,9 @@ export default function Invoices() {
if (result.success) {
setDeleteConfirm({ show: false, invoice: null });
alert.success(result.message || "Faktura byla smazána");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
@@ -283,8 +239,9 @@ export default function Invoices() {
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako zaplacená");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
@@ -323,80 +280,8 @@ export default function Invoices() {
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line w-1/4" />
<div
className="admin-skeleton-line"
style={{ width: "70px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100px" }}
/>
</div>
))}
</div>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -528,34 +413,8 @@ export default function Invoices() {
>
<Suspense
fallback={
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
@@ -574,35 +433,9 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
{!hasLoadedOnce.current && statsLoading ? (
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
{statsQuery.isPending && !hasLoadedOnce.current ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
stats && (
@@ -784,245 +617,143 @@ export default function Invoices() {
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${search}-${statsMonth}-${statsYear}-${page}-${invoices.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{" · "}
{new Date(draft.savedAt).toLocaleTimeString(
"cs-CZ",
{ hour: "2-digit", minute: "2-digit" },
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span
style={{ fontWeight: 400, opacity: 0.8 }}
>
{" · "}
{new Date(
draft.savedAt,
).toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</span>
)}
</span>
</td>
<td>
{(draft.form.customer_name as string) || "\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<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>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg
width="18"
height="18"
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>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={isOverdue ? "offers-expired-row" : ""}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 500 }}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
{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>
) : (
</td>
<td>
{(draft.form.customer_name as string) ||
"\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
@@ -1034,51 +765,11 @@ export default function Invoices() {
<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") && (
</Link>
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
onClick={discardDraft}
className="admin-btn-icon danger"
title="Smazat"
title="Zahodit koncept"
>
<svg
width="18"
@@ -1092,16 +783,195 @@ export default function Invoices() {
<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>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={
isOverdue ? "offers-expired-row" : ""
}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? {
color: "var(--danger)",
fontWeight: 600,
}
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{
textAlign: "right",
fontWeight: 500,
}}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid"
? "Detail"
: "Upravit"
}
aria-label={
inv.status === "paid"
? "Detail"
: "Upravit"
}
>
{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={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line
x1="16"
y1="13"
x2="8"
y2="13"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
/>
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
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>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>

View File

@@ -1,14 +1,19 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
leaveProcessedOptions,
} from "../lib/queries/leave";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -101,15 +106,23 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
export default function LeaveApproval() {
const { hasPermission } = useAuth();
const alert = useAlert();
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
[],
const { data: pendingData, isPending: loading } = useQuery(
leavePendingOptions(),
);
const { data: processedData } = useQuery({
...leaveProcessedOptions(),
enabled: activeTab === "processed",
});
const pendingRequests =
(pendingData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ?? [];
const pendingCount = pendingRequests.length;
const processedRequests =
(processedData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ??
[];
const [approveModal, setApproveModal] = useState<{
open: boolean;
request: LeaveRequest | null;
@@ -119,99 +132,45 @@ export default function LeaveApproval() {
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
const [processing, setProcessing] = useState(false);
useModalLock(rejectModal.open);
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=pending`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
setPendingRequests(mapped);
setPendingCount(result.pagination?.total ?? mapped.length);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
}
}, [alert]);
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=approved`,
);
if (response.status === 401) return;
const resultApproved = await response.json();
const response2 = await apiFetch(
`${API_BASE}/leave-requests?status=rejected`,
);
if (response2.status === 401) return;
const resultRejected = await response2.json();
const all = [
...(resultApproved.success
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
...(resultRejected.success
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
);
setProcessedRequests(all);
} catch {
alert.error("Nepodařilo se načíst vyřízené žádosti");
}
}, [alert]);
useEffect(() => {
setLoading(true);
fetchPending().finally(() => setLoading(false));
}, [fetchPending]);
useEffect(() => {
if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed();
}
}, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
setProcessing(true);
try {
const response = await apiFetch(
`${API_BASE}/leave-requests/${approveModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved" }),
},
);
if (response.status === 401) return;
const approveMutation = useApiMutation<
{ id: number; status: "approved" },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
onSuccess: () => {
setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena");
},
});
const result = await response.json();
if (result.success) {
setApproveModal({ open: false, request: null });
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla schválena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setProcessing(false);
const rejectMutation = useApiMutation<
{ id: number; status: "rejected"; reviewer_note: string },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
onSuccess: () => {
setRejectModal({ open: false, request: null });
setRejectNote("");
alert.success("Žádost byla zamítnuta");
},
});
const handleApprove = async () => {
if (!approveModal.request) return;
try {
await approveMutation.mutateAsync({
id: approveModal.request.id,
status: "approved",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -220,71 +179,22 @@ export default function LeaveApproval() {
alert.error("Důvod zamítnutí je povinný");
return;
}
setProcessing(true);
if (!rejectModal.request) return;
try {
const response = await apiFetch(
`${API_BASE}/leave-requests/${rejectModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
reviewer_note: rejectNote,
}),
},
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRejectModal({ open: false, request: null });
setRejectNote("");
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setProcessing(false);
await rejectMutation.mutateAsync({
id: rejectModal.request.id,
status: "rejected",
reviewer_note: rejectNote,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -370,7 +280,11 @@ export default function LeaveApproval() {
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
@@ -573,82 +487,43 @@ export default function LeaveApproval() {
}
confirmText="Schválit"
type="info"
loading={processing}
loading={approveMutation.isPending}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<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={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
/>
<motion.div
className="admin-modal"
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">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
</motion.div>
<FormModal
isOpen={rejectModal.open && !!rejectModal.request}
onClose={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
onSubmit={handleReject}
title="Zamítnout žádost"
submitLabel="Zamítnout"
loading={rejectMutation.isPending}
>
{rejectModal.request && (
<>
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</>
)}
</AnimatePresence>
</FormModal>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -35,119 +37,41 @@ const leaveTypeClasses: Record<string, string> = {
unpaid: "badge-unpaid",
};
interface LeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<LeaveRequest[]>([]);
const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
);
const [cancelModal, setCancelModal] = useState<{
open: boolean;
id: number | null;
}>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false);
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?mine=1`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRequests(result.data);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
const cancelMutation = useApiMutation<
{ id: number },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "DELETE",
invalidate: ["leave-requests", "leave", "attendance"],
onSuccess: (data) => {
setCancelModal({ open: false, id: null });
alert.success(data?.message || "Žádost byla zrušena");
},
});
if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => {
setCancelling(true);
if (!cancelModal.id) return;
try {
const response = await apiFetch(
`${API_BASE}/leave-requests/${cancelModal.id}`,
{
method: "DELETE",
},
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setCancelModal({ open: false, id: null });
await fetchRequests();
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCancelling(false);
await cancelMutation.mutateAsync({ id: cancelModal.id });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
@@ -175,6 +99,14 @@ export default function LeaveRequests() {
return <span className="text-muted"></span>;
}
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<motion.div
@@ -233,7 +165,7 @@ export default function LeaveRequests() {
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
<th>Akce</th>
</tr>
</thead>
<tbody>
@@ -269,16 +201,18 @@ export default function LeaveRequests() {
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
<div className="admin-table-actions">
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</div>
</td>
</tr>
))}
@@ -297,7 +231,7 @@ export default function LeaveRequests() {
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
loading={cancelMutation.isPending}
/>
</div>
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,18 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import {
offerCustomersOptions,
type Customer,
type CustomField,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -27,27 +32,6 @@ const CUSTOMER_FIELD_LABELS: Record<string, string> = {
vat_id: "DIČ",
};
interface Customer {
id: number;
name: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
quotation_count: number;
custom_fields?: CustomField[];
customer_field_order?: string[];
}
interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
interface CustomerForm {
name: string;
street: string;
@@ -61,8 +45,7 @@ interface CustomerForm {
export default function OffersCustomers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [customers, setCustomers] = useState<Customer[]>([]);
const { data: customers = [], isPending } = useQuery(offerCustomersOptions());
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
@@ -89,7 +72,44 @@ export default function OffersCustomers() {
}>({ show: false, customer: null });
const [deleting, setDeleting] = useState(false);
useModalLock(showModal);
const submitMutation = useApiMutation<
CustomerForm & {
custom_fields: Array<{
name: string;
value: string;
showLabel?: boolean;
}>;
customer_field_order: string[];
},
{ message?: string; error?: string }
>({
url: () =>
editingCustomer
? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`,
method: () => (editingCustomer ? "PUT" : "POST"),
invalidate: ["offer-customers", "offers"],
onSuccess: (data) => {
closeModal();
setTimeout(
() => alert.success(data?.message || "Zákazník byl uložen"),
300,
);
},
});
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/customers/${id}`,
method: () => "DELETE",
invalidate: ["offer-customers", "offers"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, customer: null });
alert.success(data?.message || "Zákazník byl smazán");
},
});
const getFullFieldOrder = useCallback(() => {
const allBuiltIn = [...DEFAULT_CUSTOMER_FIELD_ORDER];
@@ -135,27 +155,6 @@ export default function OffersCustomers() {
return key;
};
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/customers`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setCustomers(Array.isArray(result.data) ? result.data : []);
} else {
alert.error(result.error || "Nepodařilo se načíst zákazníky");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreateModal = () => {
setEditingCustomer(null);
setForm({
@@ -213,43 +212,23 @@ export default function OffersCustomers() {
return;
}
const payload = {
...form,
custom_fields: customFields
.filter((f) => f.name.trim() || f.value.trim())
.map((f) => ({
name: f.name,
value: f.value,
showLabel: f.showLabel,
})),
customer_field_order: getFullFieldOrder(),
};
setSaving(true);
try {
const url = editingCustomer
? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`;
const payload = {
...form,
custom_fields: customFields.filter(
(f) => f.name.trim() || f.value.trim(),
),
customer_field_order: getFullFieldOrder(),
};
const response = await apiFetch(url, {
method: editingCustomer ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message ||
(editingCustomer
? "Zákazník byl aktualizován"
: "Zákazník byl vytvořen"),
);
fetchData();
} else {
alert.error(result.error || "Nepodařilo se uložit zákazníka");
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(payload);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -260,30 +239,15 @@ export default function OffersCustomers() {
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/customers/${deleteConfirm.customer.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, customer: null });
alert.success(result.message || "Zákazník byl smazán");
fetchData();
} else {
alert.error(result.error || "Nepodařilo se smazat zákazníka");
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.customer.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
if (!hasPermission("offers.view")) return <Forbidden />;
if (!hasPermission("customers.view")) return <Forbidden />;
const filteredCustomers = search
? customers.filter(
@@ -294,59 +258,15 @@ export default function OffersCustomers() {
)
: customers;
if (loading) {
const fullFieldOrder = getFullFieldOrder();
if (isPending) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-10"
style={{
width: "100%",
borderRadius: "8px",
marginBottom: "0.5rem",
}}
/>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
const fullFieldOrder = getFullFieldOrder();
return (
<div>
<motion.div
@@ -359,7 +279,7 @@ export default function OffersCustomers() {
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission("offers.create") && (
{hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -404,7 +324,7 @@ export default function OffersCustomers() {
? "Žádní zákazníci odpovídající hledání."
: "Zatím nejsou žádní zákazníci."}
</p>
{!search && hasPermission("offers.create") && (
{!search && hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -457,7 +377,7 @@ export default function OffersCustomers() {
</td>
<td>
<div className="admin-table-actions">
{hasPermission("offers.edit") && (
{hasPermission("customers.edit") && (
<button
onClick={() => openEditModal(customer)}
className="admin-btn-icon"
@@ -477,7 +397,7 @@ export default function OffersCustomers() {
</svg>
</button>
)}
{hasPermission("offers.delete") && (
{hasPermission("customers.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, customer })
@@ -520,366 +440,307 @@ export default function OffersCustomers() {
</motion.div>
{/* Create/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={closeModal} />
<motion.div
className="admin-modal"
style={{ maxWidth: 720 }}
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 }}
<FormModal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingCustomer ? "Upravit zákazníka" : "Nový zákazník"}
submitLabel={editingCustomer ? "Uložit změny" : "Vytvořit zákazníka"}
loading={saving}
size="lg"
>
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="Název firmy / jméno"
/>
</FormField>
<FormField label="Ulice">
<input
type="text"
value={form.street}
onChange={(e) =>
setForm((prev) => ({ ...prev, street: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Město">
<input
type="text"
value={form.city}
onChange={(e) =>
setForm((prev) => ({ ...prev, city: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<FormField label="PSČ">
<input
type="text"
value={form.postal_code}
onChange={(e) =>
setForm((prev) => ({
...prev,
postal_code: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Země">
<input
type="text"
value={form.country}
onChange={(e) =>
setForm((prev) => ({ ...prev, country: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.company_id}
onChange={(e) =>
setForm((prev) => ({
...prev,
company_id: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.vat_id}
onChange={(e) =>
setForm((prev) => ({ ...prev, vat_id: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</div>
{/* Dynamic custom fields */}
<div style={{ marginTop: 4 }}>
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingCustomer ? "Upravit zákazníka" : "Nový zákazník"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" required>
Vlastní pole
</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div
className="admin-form-row"
style={{ marginBottom: 0, alignItems: "flex-end" }}
>
<FormField
label={idx === 0 ? "Název" : " "}
style={{ flex: 1 }}
>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
name: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
placeholder="Název firmy / jméno"
placeholder="Např. Kontakt"
/>
</FormField>
<FormField label="Ulice">
<input
type="text"
value={form.street}
onChange={(e) =>
setForm((prev) => ({ ...prev, street: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Město">
<input
type="text"
value={form.city}
onChange={(e) =>
setForm((prev) => ({ ...prev, city: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<FormField label="PSČ">
<input
type="text"
value={form.postal_code}
onChange={(e) =>
setForm((prev) => ({
...prev,
postal_code: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Země">
<input
type="text"
value={form.country}
onChange={(e) =>
setForm((prev) => ({
...prev,
country: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.company_id}
onChange={(e) =>
setForm((prev) => ({
...prev,
company_id: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.vat_id}
onChange={(e) =>
setForm((prev) => ({
...prev,
vat_id: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
</div>
{/* Dynamic custom fields */}
<div style={{ marginTop: 4 }}>
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
<FormField
label={idx === 0 ? "Hodnota" : " "}
style={{ flex: 1 }}
>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
Vlastní pole
</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div
className="admin-form-row"
style={{ marginBottom: 0, alignItems: "flex-end" }}
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`;
setFieldOrder((prev) => {
return prev
.filter((k) => k !== key)
.map((k) => {
if (k.startsWith("custom_")) {
const ki = parseInt(k.split("_")[1]);
if (ki > idx) return `custom_${ki - 1}`;
}
return k;
});
});
setCustomFields(
customFields.filter((_, i) => i !== idx),
);
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<FormField
label={idx === 0 ? "Název" : "\u00A0"}
style={{ flex: 1 }}
>
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
name: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
placeholder="Např. Kontakt"
/>
</FormField>
<FormField
label={idx === 0 ? "Hodnota" : "\u00A0"}
style={{ flex: 1 }}
>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`;
setFieldOrder((prev) => {
return prev
.filter((k) => k !== key)
.map((k) => {
if (k.startsWith("custom_")) {
const ki = parseInt(k.split("_")[1]);
if (ki > idx)
return `custom_${ki - 1}`;
}
return k;
});
});
setCustomFields(
customFields.filter((_, i) => i !== idx),
);
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label
className="admin-form-checkbox"
style={{ marginTop: 4 }}
>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
showLabel: e.target.checked,
};
setCustomFields(updated);
}}
/>
<span style={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</span>
</label>
</div>
))}
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
showLabel: e.target.checked,
};
setCustomFields(updated);
}}
/>
<span style={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</span>
</label>
</div>
))}
<button
type="button"
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`,
},
])
}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: "0.85rem" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat pole
</button>
</div>
{/* Field order for PDF */}
<div style={{ marginTop: 16 }}>
<label className="admin-form-label">Pořadí polí v PDF</label>
<small
className="admin-form-hint"
style={{ display: "block", marginBottom: 8 }}
>
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`,
},
])
}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: "0.85rem" }}
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg
width="14"
height="14"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
Přidat pole
</button>
</div>
{/* Field order for PDF */}
<div style={{ marginTop: 16 }}>
<label className="admin-form-label">
Pořadí polí v PDF
</label>
<small
className="admin-form-hint"
style={{ display: "block", marginBottom: 8 }}
>
Určuje pořadí řádků v adresním bloku zákazníka na PDF
nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<span
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
<span
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
>
{getFieldDisplayName(key)}
</span>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving && (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
)}
{!saving &&
(editingCustomer ? "Uložit změny" : "Vytvořit zákazníka")}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
))}
</div>
</div>
</div>
</FormModal>
{/* Delete Confirm Modal */}
<ConfirmModal

View File

@@ -1,44 +1,26 @@
import {
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import { useState, useRef, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import {
itemTemplatesOptions,
scopeTemplatesOptions,
type ItemTemplate,
type ScopeTemplate,
type ScopeSection,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface ItemTemplate {
id: number;
name: string;
description: string;
default_price: number;
category: string;
}
interface ScopeSection {
_key: string;
title: string;
title_cz: string;
content: string;
}
interface ScopeTemplate {
id: number;
name: string;
sections?: ScopeSection[];
}
interface ItemForm {
name: string;
description: string;
@@ -55,7 +37,7 @@ export default function OffersTemplates() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
if (!hasPermission("settings.manage")) return <Forbidden />;
if (!hasPermission("settings.templates")) return <Forbidden />;
return (
<div>
@@ -73,7 +55,7 @@ export default function OffersTemplates() {
</div>
</motion.div>
<div className="admin-tabs">
<div className="admin-tabs mb-4">
<button
className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
onClick={() => setActiveTab("items")}
@@ -97,8 +79,7 @@ export default function OffersTemplates() {
function ItemTemplatesTab() {
const alert = useAlert();
const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<ItemTemplate[]>([]);
const { data: templates = [], isPending } = useQuery(itemTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
null,
@@ -116,28 +97,31 @@ function ItemTemplatesTab() {
}>({ show: false, template: null });
const [deleting, setDeleting] = useState(false);
useModalLock(showModal);
const submitMutation = useApiMutation<
ItemForm & { id?: number },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/offers-templates?action=item`,
method: () => "POST",
invalidate: ["offer-templates", "offers"],
onSuccess: (data) => {
setShowModal(false);
setTimeout(() => alert.success(data?.message || "Uloženo"), 300);
},
});
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/offers-templates?action=items`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst šablony");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers-templates?action=item&id=${id}`,
method: () => "DELETE",
invalidate: ["offer-templates", "offers"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, template: null });
alert.success(data?.message || "Smazáno");
},
});
const openCreate = () => {
setEditingTemplate(null);
@@ -161,28 +145,12 @@ function ItemTemplatesTab() {
alert.error("Název šablony je povinný");
return;
}
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form;
setSaving(true);
try {
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form;
const response = await apiFetch(
`${API_BASE}/offers-templates?action=item`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const result = await response.json();
if (result.success) {
setShowModal(false);
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
fetchData();
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(body);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -192,50 +160,21 @@ function ItemTemplatesTab() {
if (!deleteConfirm.template) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/offers-templates?action=item&id=${deleteConfirm.template.id}`,
{ method: "DELETE" },
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
fetchData();
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.template.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
if (loading) {
if (isPending) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<>
<motion.div
@@ -346,109 +285,65 @@ function ItemTemplatesTab() {
</motion.div>
{/* Item Template Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
loading={saving}
>
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="admin-form-input"
/>
<motion.div
className="admin-modal"
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">
{editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
className="admin-form-input"
rows={2}
/>
</FormField>
<div className="admin-form-row">
<FormField label="Výchozí cena">
<input
type="number"
value={form.default_price}
onChange={(e) =>
setForm((p) => ({
...p,
default_price: parseFloat(e.target.value) || 0,
}))
}
className="admin-form-input"
step="0.01"
/>
</FormField>
<FormField label="Kategorie">
<input
type="text"
value={form.category}
onChange={(e) =>
setForm((p) => ({ ...p, category: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving && (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
)}
{!saving && (editingTemplate ? "Uložit" : "Vytvořit")}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((p) => ({
...p,
description: e.target.value,
}))
}
className="admin-form-input"
rows={2}
/>
</FormField>
<div className="admin-form-row">
<FormField label="Výchozí cena">
<input
type="number"
value={form.default_price}
onChange={(e) =>
setForm((p) => ({
...p,
default_price: parseFloat(e.target.value),
}))
}
className="admin-form-input"
step="0.01"
inputMode="decimal"
/>
</FormField>
<FormField label="Kategorie">
<input
type="text"
value={form.category}
onChange={(e) =>
setForm((p) => ({ ...p, category: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</div>
</div>
</FormModal>
<ConfirmModal
isOpen={deleteConfirm.show}
@@ -469,8 +364,7 @@ function ItemTemplatesTab() {
function ScopeTemplatesTab() {
const alert = useAlert();
const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<ScopeTemplate[]>([]);
const { data: templates = [], isPending } = useQuery(scopeTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
null,
@@ -484,26 +378,34 @@ function ScopeTemplatesTab() {
}>({ show: false, template: null });
const [deleting, setDeleting] = useState(false);
useModalLock(showModal);
const submitMutation = useApiMutation<
ScopeForm,
{ message?: string; error?: string }
>({
url: () =>
editingTemplate
? `${API_BASE}/offers-templates/${editingTemplate.id}`
: `${API_BASE}/offers-templates`,
method: () => (editingTemplate ? "PUT" : "POST"),
invalidate: ["offer-templates", "offers"],
onSuccess: (data) => {
setShowModal(false);
setTimeout(() => alert.success(data?.message || "Uloženo"), 300);
},
});
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst šablony");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers-templates/${id}`,
method: () => "DELETE",
invalidate: ["offer-templates", "offers"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, template: null });
alert.success(data?.message || "Smazáno");
},
});
const openCreate = () => {
setEditingTemplate(null);
@@ -611,26 +513,9 @@ function ScopeTemplatesTab() {
}
setSaving(true);
try {
const url = editingTemplate
? `${API_BASE}/offers-templates/${editingTemplate.id}`
: `${API_BASE}/offers-templates`;
const method = editingTemplate ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
fetchData();
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -640,50 +525,14 @@ function ScopeTemplatesTab() {
if (!deleteConfirm.template) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/offers-templates/${deleteConfirm.template.id}`,
{ method: "DELETE" },
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
fetchData();
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.template.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
);
}
return (
<>
<motion.div
@@ -784,227 +633,166 @@ function ScopeTemplatesTab() {
</motion.div>
{/* Scope Template Modal (large) */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={
editingTemplate ? "Upravit šablonu rozsahu" : "Nová šablona rozsahu"
}
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
size="lg"
loading={saving}
>
<div className="admin-form">
<FormField label="Název šablony" required>
<input
type="text"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="admin-form-input"
/>
<motion.div
className="admin-modal admin-modal-lg"
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">
{editingTemplate
? "Upravit šablonu rozsahu"
: "Nová šablona rozsahu"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název šablony" required>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</FormField>
<div className="admin-form-group">
<label className="admin-form-label mb-2">Sekce</label>
<div className="admin-scope-list">
{form.sections.map((section, index) => (
<div key={section._key} className="admin-scope-section">
<div className="admin-scope-section-header">
<span className="admin-scope-number">
{index + 1}.
</span>
<span className="admin-scope-title">
{section.title ||
section.title_cz ||
`Sekce ${index + 1}`}
</span>
<div className="admin-scope-actions">
<button
type="button"
onClick={() => moveSection(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Posunout nahoru"
aria-label="Posunout nahoru"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => moveSection(index, 1)}
disabled={index === form.sections.length - 1}
className="admin-btn-icon"
title="Posunout dolů"
aria-label="Posunout dolů"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{form.sections.length > 1 && (
<button
type="button"
onClick={() => removeSection(index)}
className="admin-btn-icon danger"
title="Odebrat"
aria-label="Odebrat"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
<div className="admin-form">
<div className="admin-form-row">
<FormField
label={
<>
<span className="offers-lang-badge">
EN
</span>{" "}
Název sekce
</>
}
>
<input
type="text"
value={section.title}
onChange={(e) =>
updateSection(
index,
"title",
e.target.value,
)
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
/>
</FormField>
<FormField
label={
<>
<span className="offers-lang-badge offers-lang-badge-cz">
CZ
</span>{" "}
Název sekce
</>
}
>
<input
type="text"
value={section.title_cz}
onChange={(e) =>
updateSection(
index,
"title_cz",
e.target.value,
)
}
className="admin-form-input"
placeholder="Název sekce (česky)"
/>
</FormField>
</div>
<FormField label="Obsah">
<RichEditor
value={section.content}
onChange={(val) =>
updateSection(index, "content", val)
}
placeholder="Obsah sekce..."
minHeight="150px"
/>
</FormField>
</div>
</div>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<div className="admin-form-group">
<label className="admin-form-label mb-2">Sekce</label>
<div className="admin-scope-list">
{form.sections.map((section, index) => (
<div key={section._key} className="admin-scope-section">
<div className="admin-scope-section-header">
<span className="admin-scope-number">{index + 1}.</span>
<span className="admin-scope-title">
{section.title ||
section.title_cz ||
`Sekce ${index + 1}`}
</span>
<div className="admin-scope-actions">
<button
type="button"
onClick={addSection}
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => moveSection(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Posunout nahoru"
aria-label="Posunout nahoru"
>
+ Přidat sekci
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => moveSection(index, 1)}
disabled={index === form.sections.length - 1}
className="admin-btn-icon"
title="Posunout dolů"
aria-label="Posunout dolů"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{form.sections.length > 1 && (
<button
type="button"
onClick={() => removeSection(index)}
className="admin-btn-icon danger"
title="Odebrat"
aria-label="Odebrat"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
<div className="admin-form">
<div className="admin-form-row">
<FormField
label={
<>
<span className="offers-lang-badge">EN</span> Název
sekce
</>
}
>
<input
type="text"
value={section.title}
onChange={(e) =>
updateSection(index, "title", e.target.value)
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
/>
</FormField>
<FormField
label={
<>
<span className="offers-lang-badge offers-lang-badge-cz">
CZ
</span>{" "}
Název sekce
</>
}
>
<input
type="text"
value={section.title_cz}
onChange={(e) =>
updateSection(index, "title_cz", e.target.value)
}
className="admin-form-input"
placeholder="Název sekce (česky)"
/>
</FormField>
</div>
<FormField label="Obsah">
<RichEditor
value={section.content}
onChange={(val) => updateSection(index, "content", val)}
placeholder="Obsah sekce..."
minHeight="150px"
/>
</FormField>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving && (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
)}
{!saving && (editingTemplate ? "Uložit" : "Vytvořit")}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<button
type="button"
onClick={addSection}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat sekci
</button>
</div>
</div>
</div>
</FormModal>
<ConfirmModal
isOpen={deleteConfirm.show}

View File

@@ -1,12 +1,15 @@
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
@@ -15,7 +18,6 @@ import ConfirmModal from "../components/ConfirmModal";
import OrderConfirmationModal from "../components/OrderConfirmationModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
@@ -45,68 +47,16 @@ const TRANSITION_CLASSES: Record<string, string> = {
dokoncena: "admin-btn admin-btn-primary",
};
interface OrderItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: number | boolean;
}
interface OrderSection {
id?: number;
title: string;
title_cz?: string;
content: string;
}
interface Invoice {
id: number;
invoice_number: string;
}
interface Project {
id: number;
project_number: string;
name: string;
has_nas_folder?: boolean;
}
interface OrderData {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
project_code?: string;
customer_name: string;
customer_order_number: string;
currency: string;
created_at: string;
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];
scope_title?: string;
scope_description?: string;
valid_transitions?: string[];
invoice?: Invoice;
project?: Project;
}
export default function OrderDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState<OrderData | null>(null);
const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data;
const loading = orderQuery.isPending;
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [statusChanging, setStatusChanging] = useState<string | null>(null);
@@ -121,39 +71,38 @@ export default function OrderDetail() {
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null);
const formInitializedRef = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// Reset form sync when navigating to a different order
useEffect(() => {
formInitializedRef.current = false;
}, [id]);
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data!;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
}
}, [orderQuery.data]);
// Navigate away on fetch error
useEffect(() => {
if (orderQuery.error) {
alert.error("Nepodařilo se načíst objednávku");
navigate("/orders");
}
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`);
if (response.status === 401) return;
const result = await response.json();
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");
}
} catch {
alert.error("Chyba připojení");
navigate("/orders");
} finally {
setLoading(false);
}
}, [id, alert, navigate]);
useEffect(() => {
fetchDetail();
}, [fetchDetail]);
const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current;
@@ -187,25 +136,37 @@ export default function OrderDetail() {
if (!hasPermission("orders.view")) return <Forbidden />;
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
});
const notesMutation = useApiMutation<{ notes: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
});
const orderDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "invoices"],
});
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
setStatusChanging(statusConfirm.status);
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: statusConfirm.status }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
fetchDetail();
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
@@ -214,20 +175,11 @@ export default function OrderDetail() {
const handleSaveNotes = async () => {
setSaving(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: notes }),
});
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");
}
} catch {
alert.error("Chyba připojení");
await notesMutation.mutateAsync({ notes });
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -303,70 +255,26 @@ export default function OrderDetail() {
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Objednávka byla smazána");
navigate("/orders");
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
}
} catch {
alert.error("Chyba připojení");
await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles });
alert.success("Objednávka byla smazána");
navigate("/orders");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
if (!order) return null;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (!order) return null;
return (
<div>
{/* Header */}
@@ -446,24 +354,26 @@ 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"
{hasPermission("orders.export") && (
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<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>
<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 &&
@@ -506,9 +416,9 @@ export default function OrderDetail() {
>
<div className="admin-card-body">
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form-row mb-2">
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Nabídka">
<div>
<div className="admin-mono fw-500">
<Link
to={`/offers/${order.quotation_id}`}
className="link-accent"
@@ -526,7 +436,7 @@ export default function OrderDetail() {
</div>
</FormField>
<FormField label="Projekt">
<div>
<div className="admin-mono fw-500">
{order.project ? (
<Link
to={`/projects/${order.project.id}`}
@@ -539,21 +449,25 @@ export default function OrderDetail() {
)}
</div>
</FormField>
<FormField label="Zákazník">
<div className="admin-mono fw-500">
{order.customer_name || "—"}
</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Zákazník">
<div className="fw-500">{order.customer_name || "—"}</div>
</FormField>
<FormField label="Číslo obj. zákazníka">
<div>{order.customer_order_number || "—"}</div>
<div className="admin-mono">
{order.customer_order_number || "—"}
</div>
</FormField>
<FormField label="Měna">
<div>{order.currency}</div>
<div className="admin-mono">{order.currency}</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Datum vytvoření">
<div>{formatDate(order.created_at)}</div>
<div className="admin-mono">{formatDate(order.created_at)}</div>
</FormField>
<FormField label="Příloha">
<div>
@@ -741,7 +655,10 @@ export default function OrderDetail() {
)}
{order.scope_description && (
<div
style={{ color: "var(--text-secondary)", marginBottom: "1rem" }}
style={{
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
{order.scope_description}
</div>

View File

@@ -6,11 +6,12 @@ import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { orderListOptions } from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination";
const API_BASE = "/api/admin";
@@ -54,94 +55,47 @@ export default function Orders() {
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "offers", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(data?.message || "Objednávka byla smazána");
},
});
const {
items: orders,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData("orders", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst objednávky",
});
isPending,
isFetching,
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/orders/${deleteConfirm.order.id}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(result.message || "Objednávka byla smazána");
fetchData();
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
await deleteMutation.mutateAsync({
id: deleteConfirm.order.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (initialLoad) {
if (isPending) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -173,7 +127,7 @@ export default function Orders() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
@@ -421,7 +375,7 @@ export default function Orders() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
loading={deleteMutation.isPending}
/>
</div>
);

View File

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

View File

@@ -1,9 +1,17 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
@@ -30,36 +38,11 @@ function formatNoteDate(dateStr: string) {
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface Note {
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number;
name: string;
}
interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
interface ProjectForm {
name: string;
status: string;
@@ -74,9 +57,7 @@ export default function ProjectDetail() {
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [project, setProject] = useState<ProjectData | null>(null);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
@@ -84,91 +65,93 @@ export default function ProjectDetail() {
end_date: "",
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setNotes(result.data.project_notes || []);
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false);
}
};
const projectQuery = useQuery(projectDetailOptions(id));
const project = projectQuery.data;
const isPending = projectQuery.isPending;
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}));
// Reset form sync when navigating to a different project
const formInitialized = useRef(false);
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const p = result.data;
setProject(p);
setForm({
name: p.name || "",
status: p.status || "aktivni",
start_date: (p.start_date || "").substring(0, 10),
end_date: (p.end_date || "").substring(0, 10),
responsible_user_id: p.responsible_user_id || "",
});
} else {
alert.error(result.error || "Nepodařilo se načíst projekt");
navigate("/projects");
}
} catch {
alert.error("Chyba připojení");
navigate("/projects");
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
const raw = Array.isArray(data.data)
? data.data
: data.data?.items || [];
setUsers(
raw.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
// silent
}
};
formInitialized.current = false;
}, [id]);
fetchDetail();
fetchNotes();
fetchUsers();
}, [id, alert, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync project data to local form state on first load
useEffect(() => {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
});
formInitialized.current = true;
}
}, [project]);
// Navigate away on fetch error
useEffect(() => {
if (projectQuery.error) {
alert.error("Nepodařilo se načíst projekt");
navigate("/projects");
}
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission("projects.view")) return <Forbidden />;
const projectSaveMutation = useApiMutation<
{
name: string;
status: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
},
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "warehouse"],
});
const projectDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
const projectAddNoteMutation = useApiMutation<{ content: string }, unknown>({
url: () => `${API_BASE}/projects/${id}/notes`,
method: () => "POST",
invalidate: ["projects", "warehouse"],
});
const projectDeleteNoteMutation = useApiMutation<number, unknown>({
url: (noteId) => `${API_BASE}/projects/${id}/notes/${noteId}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
@@ -180,25 +163,16 @@ export default function ProjectDetail() {
setSaving(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
}),
await projectSaveMutation.mutateAsync({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Projekt byl aktualizován");
} else {
alert.error(result.error || "Nepodařilo se uložit projekt");
}
} catch {
alert.error("Chyba připojení");
alert.success("Projekt byl aktualizován");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -207,20 +181,11 @@ export default function ProjectDetail() {
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
});
const result = await response.json();
if (result.success) {
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else {
alert.error(result.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error("Chyba připojení");
await projectDeleteMutation.mutateAsync({ delete_files: deleteFiles });
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
@@ -231,21 +196,11 @@ export default function ProjectDetail() {
setAddingNote(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newNote.trim() }),
});
const result = await response.json();
if (result.success) {
setNotes((prev) => [result.data.note, ...prev]);
setNewNote("");
alert.success("Poznámka byla přidána");
} else {
alert.error(result.error || "Nepodařilo se přidat poznámku");
}
} catch {
alert.error("Chyba připojení");
await projectAddNoteMutation.mutateAsync({ content: newNote.trim() });
setNewNote("");
alert.success("Poznámka byla přidána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setAddingNote(false);
}
@@ -254,72 +209,27 @@ export default function ProjectDetail() {
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId);
try {
const response = await apiFetch(
`${API_BASE}/projects/${id}/notes/${noteId}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
alert.success("Poznámka byla smazána");
} else {
alert.error(result.error || "Nepodařilo se smazat poznámku");
}
} catch {
alert.error("Chyba připojení");
await projectDeleteNoteMutation.mutateAsync(noteId);
alert.success("Poznámka byla smazána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeletingNoteId(null);
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
if (!project) return null;
const canEdit = hasPermission("projects.edit");
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
{/* Header */}
@@ -548,18 +458,7 @@ export default function ProjectDetail() {
)}
{/* Notes list */}
{notesLoading && (
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "8px" }}
/>
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
{notes.length === 0 && !project.notes && (
<div
style={{
color: "var(--text-tertiary)",
@@ -571,7 +470,7 @@ export default function ProjectDetail() {
Zatím žádné poznámky
</div>
)}
{!notesLoading && (notes.length > 0 || project.notes) && (
{(notes.length > 0 || project.notes) && (
<div
style={{
display: "flex",

View File

@@ -6,11 +6,12 @@ import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { projectListOptions } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination";
const API_BASE = "/api/admin";
@@ -48,94 +49,59 @@ export default function Projects() {
useTableSort("project_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/projects/${id}`,
method: () => "DELETE",
invalidate: [
"projects",
"warehouse",
"orders",
"offers",
"invoices",
"attendance",
],
onSuccess: (data) => {
alert.success(data?.message || "Projekt byl smazán");
setDeleteTarget(null);
setDeleteFiles(false);
},
});
const {
items: projects,
setItems: setProjects,
loading,
initialLoad,
pagination,
} = useListData<Project>("projects", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst projekty",
});
isPending,
isFetching,
} = usePaginatedQuery<Project>(
projectListOptions({ search, sort, order, page }),
);
if (!hasPermission("projects.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteTarget) return;
setDeletingId(deleteTarget.id);
try {
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
await deleteMutation.mutateAsync({
id: deleteTarget.id,
delete_files: deleteFiles,
});
const data = await res.json();
if (data.success) {
alert.success(data.message || "Projekt byl smazán");
setProjects((prev: Project[]) =>
prev.filter((p) => p.id !== deleteTarget.id),
);
} else {
alert.error(data.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeletingId(null);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
setDeleteTarget(null);
setDeleteFiles(false);
}
};
if (initialLoad) {
if (isPending) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -160,22 +126,6 @@ export default function Projects() {
)}
</p>
</div>
{hasPermission("projects.create") && (
<Link to="/projects/new" className="admin-btn admin-btn-primary">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nový projekt
</Link>
)}
</motion.div>
<motion.div
@@ -183,7 +133,7 @@ export default function Projects() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
@@ -217,10 +167,12 @@ export default function Projects() {
</div>
<p>Zatím nejsou žádné projekty.</p>
<p
style={{ color: "var(--text-tertiary)", fontSize: "0.875rem" }}
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
Projekt se vytvoří automaticky při vytvoření objednávky.
</p>
</div>
) : (
@@ -338,14 +290,18 @@ export default function Projects() {
<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>
{!p.order_id && hasPermission("projects.create") && (
{!p.order_id && hasPermission("projects.delete") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
title="Smazat projekt"
disabled={deletingId === p.id}
disabled={
deleteMutation.isPending &&
deleteTarget?.id === p.id
}
>
{deletingId === p.id ? (
{deleteMutation.isPending &&
deleteTarget?.id === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg
@@ -401,7 +357,7 @@ export default function Projects() {
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}
loading={deleteMutation.isPending}
/>
</div>
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,27 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import {
tripListOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance?: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes?: string | null;
users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string };
}
interface TripForm {
vehicle_id: string;
trip_date: string;
@@ -49,12 +36,17 @@ interface TripForm {
export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const trips = tripsData ?? [];
const vehicles = vehiclesData ?? [];
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editingTrip, setEditingTrip] = useState<BackendTrip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
tripId: number | null;
@@ -72,41 +64,35 @@ export default function Trips() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0);
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
]);
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[alert],
);
useEffect(() => {
fetchData();
}, [fetchData]);
useModalLock(showModal);
if (!hasPermission("trips.record")) return <Forbidden />;
const submitMutation = useApiMutation<
TripForm,
{ message?: string; error?: string }
>({
url: (formData) =>
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
method: () => (editingTrip ? "PUT" : "POST"),
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Hotovo");
},
});
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/trips/${id}`,
method: () => "DELETE",
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
alert.success(data?.message || "Smazáno");
setDeleteConfirm({ show: false, tripId: null });
},
});
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0);
@@ -147,7 +133,7 @@ export default function Trips() {
setShowModal(true);
};
const openEditModal = (trip: Trip) => {
const openEditModal = (trip: BackendTrip) => {
setEditingTrip(trip);
setForm({
vehicle_id: String(trip.vehicle_id),
@@ -190,55 +176,18 @@ export default function Trips() {
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitting(true);
try {
const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips`;
const response = await apiFetch(url, {
method: editingTrip ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSubmitting(false);
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async (tripId: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleteConfirm({ show: false, tripId: null });
await deleteMutation.mutateAsync(tripId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -248,64 +197,10 @@ export default function Trips() {
return end > start ? end - start : 0;
};
if (loading) {
if (tripsLoading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -615,209 +510,145 @@ export default function Trips() {
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
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 }}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
loading={submitMutation.isPending}
size="lg"
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val });
setErrors((prev) => ({ ...prev, trip_date: "" }));
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
</h2>
</div>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField
label="Vozidlo"
error={errors.vehicle_id}
required
>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Konečný stav km" error={errors.end_km} required>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value });
setErrors((prev) => ({ ...prev, end_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField
label="Datum jízdy"
error={errors.trip_date}
required
>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val });
setErrors((prev) => ({ ...prev, trip_date: "" }));
}}
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value });
setErrors((prev) => ({ ...prev, route_from: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField
label="Konečný stav km"
error={errors.end_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value });
setErrors((prev) => ({ ...prev, end_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value });
setErrors((prev) => ({ ...prev, route_to: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<div className="admin-form-row">
<FormField
label="Místo odjezdu"
error={errors.route_from}
required
>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value });
setErrors((prev) => ({ ...prev, route_from: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField
label="Místo příjezdu"
error={errors.route_to}
required
>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value });
setErrors((prev) => ({ ...prev, route_to: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</FormModal>
<ConfirmModal
isOpen={deleteConfirm.show}
@@ -828,6 +659,7 @@ export default function Trips() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleteMutation.isPending}
/>
</div>
);

View File

@@ -1,30 +1,32 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripVehicle,
type TripUser,
} from "../lib/queries/trips";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface UserShort {
id: number | string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
@@ -40,22 +42,6 @@ interface Trip {
driver_name: string;
}
interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
interface EditForm {
vehicle_id: string;
trip_date: string;
@@ -88,8 +74,6 @@ function mapTrip(bt: BackendTrip): Trip {
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),
);
@@ -98,9 +82,6 @@ export default function TripsAdmin() {
);
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [users, setUsers] = useState<UserShort[]>([]);
const printRef = useRef<HTMLDivElement>(null);
const [showEditModal, setShowEditModal] = useState(false);
@@ -121,60 +102,62 @@ export default function TripsAdmin() {
trip: Trip | null;
}>({ show: false, trip: null });
// Fetch vehicles and users once on mount
useEffect(() => {
const fetchLookups = async () => {
try {
const [vRes, uRes, csRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
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);
}
} catch {
// silently fail, filters will just be empty
}
};
fetchLookups();
}, []);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData;
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
if (filterUserId) url += `&user_id=${filterUserId}`;
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
const tripUsers = tripUsersData;
const response = await apiFetch(url);
const result = await response.json();
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip);
setTrips(mapped);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterMonth) || undefined,
year: Number(filterYear) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
}),
);
const trips = (tripsData ?? []).map(mapTrip);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!hasPermission("trips.manage")) return <Forbidden />;
useModalLock(showEditModal);
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
// NOT a wrapper that nests the body. id is captured in the URL closure.
const editMutation = useApiMutation<
{
id: number;
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
},
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/trips/${id}`,
method: () => "PUT",
invalidate: ["trips", "vehicles"],
});
if (!hasPermission("trips.admin")) return <Forbidden />;
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/trips/${id}`,
method: () => "DELETE",
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, trip: null });
alert.success(data?.message || "Smazáno");
},
});
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);
@@ -201,49 +184,30 @@ export default function TripsAdmin() {
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
const result = await editMutation.mutateAsync({
id: editingTrip.id,
vehicle_id: editForm.vehicle_id,
trip_date: editForm.trip_date,
start_km: editForm.start_km,
end_km: editForm.end_km,
route_from: editForm.route_from,
route_to: editForm.route_to,
is_business: editForm.is_business,
notes: editForm.notes,
});
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.trip) return;
try {
const response = await apiFetch(
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, trip: null });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.trip.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -259,7 +223,7 @@ export default function TripsAdmin() {
};
const getSelectedUserName = () => {
if (!filterUserId) return null;
const u = users.find((u) => String(u.id) === filterUserId);
const u = tripUsers.find((u) => String(u.id) === filterUserId);
return u?.name || null;
};
@@ -468,7 +432,7 @@ export default function TripsAdmin() {
className="admin-form-select"
>
<option value="">Všichni řidiči</option>
{users.map((u) => (
{tripUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
@@ -565,302 +529,262 @@ export default function TripsAdmin() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
<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>
<button
onClick={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
<button
onClick={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingTrip && (
<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={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
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 }}
<FormModal
isOpen={showEditModal && !!editingTrip}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit jízdu"
loading={editMutation.isPending}
size="lg"
>
{editingTrip && (
<div className="admin-form">
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
{editingTrip.driver_name}
</p>
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{editingTrip.driver_name}
</p>
</div>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
)}
</AnimatePresence>
</FormModal>
{/* Delete Confirmation */}
<ConfirmModal
@@ -875,6 +799,7 @@ export default function TripsAdmin() {
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
@@ -7,15 +8,12 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
import {
tripHistoryOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
interface Trip {
id: number;
@@ -34,14 +32,37 @@ interface Trip {
export default function TripsHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData;
const { data: tripsData, isPending } = useQuery(
tripHistoryOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
}),
);
const trips = (tripsData ?? []).map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
driver_name: t.users
? `${t.users.first_name} ${t.users.last_name}`.trim()
: "",
route_from: t.route_from,
route_to: t.route_to,
start_km: t.start_km,
end_km: t.end_km,
distance: t.distance ?? t.end_km - t.start_km,
is_business: t.is_business,
notes: t.notes ?? undefined,
}));
const totals = trips.reduce(
(acc, t) => ({
@@ -52,52 +73,6 @@ export default function TripsHistory() {
{ total: 0, business: 0, count: 0 },
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ month });
if (user?.id) params.set("user_id", String(user.id));
if (vehicleId) params.set("vehicle_id", vehicleId);
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
]);
if (tripsRes.status === 401) return;
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data)
? tripsResult.data
: tripsResult.data?.items || [];
setTrips(
raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance:
((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})),
);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, vehicleId, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => {
@@ -240,87 +215,85 @@ export default function TripsHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>

View File

@@ -1,32 +1,21 @@
import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import { useApiMutation } from "../lib/queries/mutations";
import {
userListOptions,
roleListOptions,
type User,
} from "../lib/queries/users";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
interface Role {
id: number;
name: string;
display_name: string;
}
interface FormData {
username: string;
email: string;
@@ -40,16 +29,16 @@ interface FormData {
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const { data: usersData, isPending } = useQuery(userListOptions());
const users = usersData ?? [];
const { data: roles = [] } = useQuery(roleListOptions());
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<FormData>({
username: "",
email: "",
@@ -60,39 +49,61 @@ export default function Users() {
is_active: true,
});
useModalLock(showModal);
const USER_INVALIDATE = [
"users",
"trips",
"attendance",
"leave-requests",
"leave",
"projects",
];
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`);
const usersData = await usersRes.json();
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : []);
} else {
alert.error(usersData.error || "Nepodařilo se načíst uživatele");
const saveUser = useApiMutation<FormData, void>({
url: (input) =>
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
method: () => (editingUser ? "PUT" : "POST"),
invalidate: USER_INVALIDATE,
onSuccess: async (_data, input) => {
if (
editingUser &&
currentUser &&
Number(editingUser.id) === Number(currentUser.id)
) {
updateUser({
username: input.username,
email: input.email,
fullName: `${input.first_name} ${input.last_name}`.trim(),
});
}
setShowModal(false);
setEditingUser(null);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
editingUser ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
},
});
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try {
const rolesRes = await apiFetch(`${API_BASE}/roles`);
const rolesData = await rolesRes.json();
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
}
} catch {
/* roles not accessible */
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const deleteUser = useApiMutation<number, void>({
url: (id) => `${API_BASE}/users/${id}`,
method: () => "DELETE",
invalidate: USER_INVALIDATE,
onSuccess: () => {
setDeleteModal({ isOpen: false, user: null });
alert.success("Uživatel byl smazán");
},
});
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const toggleUser = useApiMutation<{ id: number; is_active: boolean }, void>({
url: (input) => `${API_BASE}/users/${input.id}`,
method: () => "PUT",
invalidate: USER_INVALIDATE,
onSuccess: (_data, input) => {
alert.success(
input.is_active ? "Uživatel byl aktivován" : "Uživatel byl deaktivován",
);
},
});
if (!hasPermission("users.view")) return <Forbidden />;
@@ -129,51 +140,14 @@ export default function Users() {
setEditingUser(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
const wasEditing = editingUser;
const editingId = editingUser?.id;
const handleSubmit = async () => {
setSaving(true);
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`;
const method = wasEditing ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json();
if (data.success) {
if (
wasEditing &&
currentUser &&
Number(editingId) === Number(currentUser.id)
) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
}
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
fetchUsers();
} else {
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
} catch {
alert.error("Chyba připojení");
await saveUser.mutateAsync(formData);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -187,57 +161,18 @@ export default function Users() {
const handleDelete = async () => {
if (!deleteModal.user) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/users/${deleteModal.user.id}`,
{
method: "DELETE",
},
);
const data = await response.json();
if (data.success) {
closeDeleteModal();
fetchUsers();
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || "Nepodařilo se smazat uživatele");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
await deleteUser.mutateAsync(deleteModal.user.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (user: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
is_active: !user.is_active,
}),
});
const data = await response.json();
if (data.success) {
fetchUsers();
alert.success(
user.is_active
? "Uživatel byl deaktivován"
: "Uživatel byl aktivován",
);
} else {
alert.error(data.error || "Nepodařilo se změnit stav uživatele");
}
} catch {
alert.error("Chyba připojení");
}
const toggleActive = (user: User) => {
toggleUser.mutate({
id: user.id,
is_active: !user.is_active,
});
};
const getRoleBadgeClass = (roleName: string): string => {
@@ -249,42 +184,10 @@ export default function Users() {
}
};
if (loading) {
if (isPending) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -400,6 +303,8 @@ export default function Users() {
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
@@ -419,6 +324,8 @@ export default function Users() {
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
@@ -435,155 +342,116 @@ export default function Users() {
</div>
</motion.div>
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
submitLabel={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
loading={saving}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<div className="admin-modal-backdrop" onClick={closeModal} />
<motion.div
className="admin-modal"
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 }}
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
</div>
</FormModal>
<ConfirmModal
isOpen={deleteModal.isOpen}
@@ -594,7 +462,7 @@ export default function Users() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
loading={deleteUser.isPending}
/>
</div>
);

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