Compare commits
38 Commits
3481b97d47
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5233db2002 | ||
|
|
dac45baaa8 | ||
|
|
f389c4b3cf | ||
|
|
fdc7037e60 | ||
|
|
b02f6afe93 | ||
|
|
1b2ab7b4eb | ||
|
|
29975ecad9 | ||
|
|
e846f15126 | ||
|
|
86bebf9776 | ||
|
|
7b7c6bde68 | ||
|
|
935fb235a6 | ||
|
|
d2d1cdbb92 | ||
|
|
5f08b0e086 | ||
|
|
637499a8ec | ||
|
|
f413bae348 | ||
|
|
2b1de583fd | ||
|
|
5500cdb118 | ||
|
|
2254a13ad0 | ||
|
|
1f5885de84 | ||
|
|
705f58e3d1 | ||
|
|
0330453ad6 | ||
|
|
66b98e2765 | ||
|
|
55648c9a30 | ||
|
|
cd6d3daf43 | ||
|
|
1db5060c42 | ||
|
|
4cabba395d | ||
|
|
59b478f262 | ||
|
|
e4f14a24b7 | ||
|
|
3bd0d055d9 | ||
|
|
746d17e182 | ||
|
|
e96e51598a | ||
|
|
9abec36f07 | ||
|
|
ecd8e3679f | ||
|
|
ba95723b61 | ||
|
|
12289bdce3 | ||
|
|
d1c5234a03 | ||
|
|
27cc876e82 | ||
|
|
82919d39f6 |
@@ -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
108
CLAUDE.md
@@ -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
37
boneyard.config.json
Normal 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
412
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add billing_text column to invoices table
|
||||
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE `company_settings`
|
||||
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
|
||||
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
@@ -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}';
|
||||
@@ -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"
|
||||
|
||||
@@ -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
469
prisma/seed.ts
Normal 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());
|
||||
1244
src/__tests__/warehouse.test.ts
Normal file
1244
src/__tests__/warehouse.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -330,15 +330,6 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Additional Utilities ─────────────────────────────────────────── */
|
||||
|
||||
/* Font sizes */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
118
src/admin/components/FormModal.tsx
Normal file
118
src/admin/components/FormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
src/admin/components/warehouse/BatchPicker.tsx
Normal file
74
src/admin/components/warehouse/BatchPicker.tsx
Normal 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)} Kč
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/admin/components/warehouse/ItemPicker.tsx
Normal file
228
src/admin/components/warehouse/ItemPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/admin/components/warehouse/LocationSelect.tsx
Normal file
31
src/admin/components/warehouse/LocationSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/admin/components/warehouse/ReservationPicker.tsx
Normal file
54
src/admin/components/warehouse/ReservationPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/admin/components/warehouse/SupplierSelect.tsx
Normal file
33
src/admin/components/warehouse/SupplierSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
src/admin/components/warehouse/WarehouseMovementTable.tsx
Normal file
249
src/admin/components/warehouse/WarehouseMovementTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, "'");
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
44
src/admin/hooks/usePaginatedQuery.ts
Normal file
44
src/admin/hooks/usePaginatedQuery.ts
Normal 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 };
|
||||
24
src/admin/hooks/useReducedMotion.ts
Normal file
24
src/admin/hooks/useReducedMotion.ts
Normal 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;
|
||||
}
|
||||
83
src/admin/lib/apiAdapter.ts
Normal file
83
src/admin/lib/apiAdapter.ts
Normal 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 };
|
||||
}
|
||||
185
src/admin/lib/queries/attendance.ts
Normal file
185
src/admin/lib/queries/attendance.ts
Normal 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,
|
||||
});
|
||||
28
src/admin/lib/queries/auditLog.ts
Normal file
28
src/admin/lib/queries/auditLog.ts
Normal 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}` : ""}`);
|
||||
},
|
||||
});
|
||||
28
src/admin/lib/queries/common.ts
Normal file
28
src/admin/lib/queries/common.ts
Normal 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,
|
||||
});
|
||||
42
src/admin/lib/queries/dashboard.ts
Normal file
42
src/admin/lib/queries/dashboard.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
209
src/admin/lib/queries/invoices.ts
Normal file
209
src/admin/lib/queries/invoices.ts
Normal 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,
|
||||
});
|
||||
42
src/admin/lib/queries/leave.ts
Normal file
42
src/admin/lib/queries/leave.ts
Normal 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",
|
||||
),
|
||||
});
|
||||
113
src/admin/lib/queries/mutations.ts
Normal file
113
src/admin/lib/queries/mutations.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
160
src/admin/lib/queries/offers.ts
Normal file
160
src/admin/lib/queries/offers.ts
Normal 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",
|
||||
),
|
||||
});
|
||||
84
src/admin/lib/queries/orders.ts
Normal file
84
src/admin/lib/queries/orders.ts
Normal 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,
|
||||
});
|
||||
111
src/admin/lib/queries/projects.ts
Normal file
111
src/admin/lib/queries/projects.ts
Normal 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 || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
87
src/admin/lib/queries/settings.ts
Normal file
87
src/admin/lib/queries/settings.ts
Normal 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"),
|
||||
});
|
||||
104
src/admin/lib/queries/trips.ts
Normal file
104
src/admin/lib/queries/trips.ts
Normal 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}` : ""}`);
|
||||
},
|
||||
});
|
||||
37
src/admin/lib/queries/users.ts
Normal file
37
src/admin/lib/queries/users.ts
Normal 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,
|
||||
});
|
||||
8
src/admin/lib/queries/vehicles.ts
Normal file
8
src/admin/lib/queries/vehicles.ts
Normal 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"),
|
||||
});
|
||||
430
src/admin/lib/queries/warehouse.ts
Normal file
430
src/admin/lib/queries/warehouse.ts
Normal 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}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
13
src/admin/lib/queryClient.ts
Normal file
13
src/admin/lib/queryClient.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} → {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} → {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 */}
|
||||
|
||||
@@ -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} → {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} → {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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user