Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9720a67a |
43
.claude/skills/compare-php/SKILL.md
Normal file
43
.claude/skills/compare-php/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
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\`
|
||||||
@@ -7,13 +7,13 @@ HOST=127.0.0.1
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
|
|
||||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||||
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
JWT_SECRET=generate-with-openssl-rand-hex-32
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
ACCESS_TOKEN_EXPIRY=900
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||||
|
|
||||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
||||||
|
|
||||||
# File storage
|
# File storage
|
||||||
NAS_PATH=Z:/02_PROJEKTY
|
NAS_PATH=Z:/02_PROJEKTY
|
||||||
|
|||||||
390
CLAUDE.md
390
CLAUDE.md
@@ -1,390 +0,0 @@
|
|||||||
# CLAUDE.md — boha-app-ts
|
|
||||||
|
|
||||||
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
|
||||||
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
| -------------- | ------------------------------------------------------------- |
|
|
||||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
|
||||||
| HTTP Framework | Fastify 5.8.2 |
|
|
||||||
| ORM | Prisma 6.19.2 → MySQL |
|
|
||||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
|
||||||
| Validation | Zod 4.3.6 |
|
|
||||||
| Frontend | React 18.3.1 + Vite 8.0.0 |
|
|
||||||
| Testing | Vitest 4.1.0 + Supertest |
|
|
||||||
| PDF | Puppeteer 24.x |
|
|
||||||
| Email | nodemailer 8.x |
|
|
||||||
| Cron | node-cron 4.x |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── server.ts # Fastify server entry point — plugins, routes, error handler
|
|
||||||
├── routes/admin/ # HTTP route handlers (one file per entity)
|
|
||||||
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
|
|
||||||
├── schemas/ # Zod validation schemas (one file per entity)
|
|
||||||
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
|
|
||||||
│ # security.ts (CSP, HSTS, security headers)
|
|
||||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
|
||||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
|
||||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
|
||||||
├── admin/ # React 18 frontend (57 .tsx files)
|
|
||||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
|
||||||
│ ├── contexts/ # AuthContext, AlertContext
|
|
||||||
│ ├── components/ # Layout, modals, tables, editors
|
|
||||||
│ ├── pages/ # One file per page/feature
|
|
||||||
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
|
|
||||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
|
||||||
└── __tests__/ # Vitest tests (auth, numbering)
|
|
||||||
|
|
||||||
prisma/
|
|
||||||
├── schema.prisma # 32 models, MySQL, snake_case columns
|
|
||||||
└── migrations/ # Applied migrations
|
|
||||||
|
|
||||||
dist/ # Compiled server (CommonJS, ES2022)
|
|
||||||
dist-client/ # Built frontend (Vite, ES2020)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
npm run dev # Starts server in watch mode (manage frontend separately)
|
|
||||||
npm run dev:server # tsx watch src/server.ts
|
|
||||||
npm run dev:client # Vite dev server
|
|
||||||
|
|
||||||
# Build
|
|
||||||
npm run build # Build server + client
|
|
||||||
npm run build:server # tsc -p tsconfig.server.json → dist/
|
|
||||||
npm run build:client # vite build → dist-client/
|
|
||||||
|
|
||||||
# Run (production)
|
|
||||||
npm start # node dist/server.js
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
npm test # vitest run (single pass)
|
|
||||||
npm run test:watch # vitest watch
|
|
||||||
|
|
||||||
# Database
|
|
||||||
npx prisma migrate dev # 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
|
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL=mysql://user:pass@host:3306/dbname
|
|
||||||
JWT_SECRET=<64-char hex string>
|
|
||||||
TOTP_ENCRYPTION_KEY=<64-char hex string>
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional (with defaults):
|
|
||||||
|
|
||||||
```
|
|
||||||
PORT=3001 # Production port (dev default: 3000)
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
|
||||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
|
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
|
|
||||||
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
|
|
||||||
MAX_UPLOAD_SIZE=52428800 # 50MB
|
|
||||||
CONTACT_EMAIL_TO=
|
|
||||||
CONTACT_EMAIL_FROM=
|
|
||||||
SMTP_FROM=
|
|
||||||
LEAVE_NOTIFY_EMAIL=
|
|
||||||
APP_URL= # Used in email links
|
|
||||||
CORS_ORIGINS= # Comma-separated, production only
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `.env` for dev, `.env.test` for tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture & Key Patterns
|
|
||||||
|
|
||||||
### Request Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Request → CORS → Cookie → Rate-limit → Security headers
|
|
||||||
→ requirePermission() or requireAuth()
|
|
||||||
→ Zod schema validation (parseBody helper)
|
|
||||||
→ Route handler
|
|
||||||
→ Service function
|
|
||||||
→ Prisma
|
|
||||||
→ success(reply, data) or error(reply, message, status)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
All responses use this shape:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Success
|
|
||||||
{ success: true, data: T, message?: string, pagination?: {...} }
|
|
||||||
|
|
||||||
// Error
|
|
||||||
{ success: false, error: string }
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
|
|
||||||
|
|
||||||
### Service Pattern
|
|
||||||
|
|
||||||
Services are plain exported async functions, no classes:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/services/foo.service.ts
|
|
||||||
export async function getFoo(id: number) {
|
|
||||||
const result = await prisma.foo.findUnique({ where: { id } });
|
|
||||||
if (!result) return { error: "Not found", status: 404 };
|
|
||||||
return { data: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/routes/admin/foo.ts
|
|
||||||
const result = await getFoo(id);
|
|
||||||
if ("error" in result) return error(reply, result.error, result.status ?? 400);
|
|
||||||
return success(reply, result.data);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Routes map service errors to HTTP responses using the pattern above.
|
|
||||||
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
|
|
||||||
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
|
|
||||||
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Route-level guard
|
|
||||||
fastify.addHook("preHandler", requirePermission("invoices.view"));
|
|
||||||
// or multiple
|
|
||||||
fastify.addHook(
|
|
||||||
"preHandler",
|
|
||||||
requirePermission("invoices.view", "invoices.edit"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin role bypasses all permission checks
|
|
||||||
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audit Logging
|
|
||||||
|
|
||||||
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
|
|
||||||
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const body = parseBody(FooSchema, request.body);
|
|
||||||
if ("error" in body) return error(reply, body.error, 400);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Date & Timezone Handling (Critical Gotcha)
|
|
||||||
|
|
||||||
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
|
|
||||||
`Date.prototype.toJSON()` to return local time (not UTC). This means:
|
|
||||||
|
|
||||||
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
|
|
||||||
- All API responses with Date fields will contain local time strings.
|
|
||||||
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
|
|
||||||
- **Never assume UTC** when working with Date objects in this codebase.
|
|
||||||
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
|
|
||||||
- The override exists for PHP migration compatibility and Czech date display.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TOTP / 2FA
|
|
||||||
|
|
||||||
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
|
|
||||||
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
|
|
||||||
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
|
|
||||||
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
|
|
||||||
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
|
|
||||||
|
|
||||||
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
|
||||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
|
||||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
|
||||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
|
||||||
|
|
||||||
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Conventions
|
|
||||||
|
|
||||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
|
||||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
|
||||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
|
||||||
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
|
|
||||||
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
|
||||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
|
||||||
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
|
|
||||||
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
|
|
||||||
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
|
|
||||||
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
|
||||||
|
|
||||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
|
||||||
|
|
||||||
4. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
|
|
||||||
|
|
||||||
5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
|
|
||||||
|
|
||||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
|
||||||
|
|
||||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
|
||||||
|
|
||||||
8. **Prisma client regeneration** — After any schema change 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.
|
|
||||||
|
|
||||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release Process
|
|
||||||
|
|
||||||
1. Bump version in `package.json`
|
|
||||||
2. `npm run build`
|
|
||||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
|
||||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
|
||||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
|
||||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
|
||||||
- Path: `/var/www/app-ts`
|
|
||||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
|
||||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
|
||||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
|
||||||
- Install dependencies: `npm install --omit=dev`
|
|
||||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
|
||||||
- Restart: `pm2 restart app-ts --update-env`
|
|
||||||
|
|
||||||
Do not push directly to production or restart services without confirmation.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
953
package-lock.json
generated
953
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.8.0",
|
"version": "1.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,11 +17,7 @@
|
|||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest"
|
||||||
"seed": "tsx prisma/seed.ts"
|
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"seed": "tsx prisma/seed.ts"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -38,8 +34,6 @@
|
|||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tanstack/react-query": "^5.100.5",
|
|
||||||
"@types/jsdom": "^28.0.1",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
@@ -48,7 +42,6 @@
|
|||||||
"file-type": "^16.5.4",
|
"file-type": "^16.5.4",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"jsdom": "^29.0.2",
|
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ CREATE TABLE `company_settings` (
|
|||||||
`vat_id` VARCHAR(50) NULL,
|
`vat_id` VARCHAR(50) NULL,
|
||||||
`custom_fields` LONGTEXT NULL,
|
`custom_fields` LONGTEXT NULL,
|
||||||
`logo_data` LONGBLOB NULL,
|
`logo_data` LONGBLOB NULL,
|
||||||
`logo_data_dark` MEDIUMBLOB NULL,
|
|
||||||
`quotation_prefix` VARCHAR(20) NULL,
|
`quotation_prefix` VARCHAR(20) NULL,
|
||||||
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||||
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||||
@@ -109,24 +108,7 @@ CREATE TABLE `company_settings` (
|
|||||||
`order_type_code` VARCHAR(10) NULL,
|
`order_type_code` VARCHAR(10) NULL,
|
||||||
`invoice_type_code` VARCHAR(10) NULL,
|
`invoice_type_code` VARCHAR(10) NULL,
|
||||||
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
|
`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`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -185,18 +167,14 @@ CREATE TABLE `invoices` (
|
|||||||
`tax_date` DATE NULL,
|
`tax_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`issued_by` VARCHAR(255) NULL,
|
`issued_by` VARCHAR(255) NULL,
|
||||||
`billing_text` VARCHAR(500) NULL,
|
|
||||||
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
|
||||||
`notes` TEXT NULL,
|
`notes` TEXT NULL,
|
||||||
`internal_notes` TEXT NULL,
|
`internal_notes` TEXT NULL,
|
||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
|
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_invoices_due_date`(`due_date`),
|
INDEX `idx_invoices_due_date`(`due_date`),
|
||||||
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
||||||
INDEX `idx_invoices_status_due`(`status`, `due_date`),
|
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -261,7 +239,6 @@ CREATE TABLE `number_sequences` (
|
|||||||
`year` INTEGER NULL,
|
`year` INTEGER NULL,
|
||||||
`last_number` INTEGER NULL DEFAULT 0,
|
`last_number` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
|
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -317,7 +294,6 @@ CREATE TABLE `orders` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
|
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `quotation_id`(`quotation_id`),
|
INDEX `quotation_id`(`quotation_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -365,7 +341,6 @@ CREATE TABLE `projects` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
UNIQUE INDEX `projects_project_number_key`(`project_number`),
|
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
@@ -411,13 +386,10 @@ CREATE TABLE `quotations` (
|
|||||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
`scope_title` VARCHAR(500) NULL,
|
`scope_title` VARCHAR(500) NULL,
|
||||||
`scope_description` TEXT NULL,
|
`scope_description` TEXT NULL,
|
||||||
`locked_by` INTEGER NULL,
|
|
||||||
`locked_at` DATETIME(0) NULL,
|
|
||||||
`uuid` VARCHAR(36) NULL,
|
`uuid` VARCHAR(36) NULL,
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
`sync_version` INTEGER NULL DEFAULT 0,
|
`sync_version` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
|
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_quotations_number`(`quotation_number`),
|
INDEX `idx_quotations_number`(`quotation_number`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -439,6 +411,7 @@ CREATE TABLE `received_invoices` (
|
|||||||
`due_date` DATE NULL,
|
`due_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
||||||
|
`file_data` MEDIUMBLOB NULL,
|
||||||
`file_name` VARCHAR(255) NULL,
|
`file_name` VARCHAR(255) NULL,
|
||||||
`file_mime` VARCHAR(100) NULL,
|
`file_mime` VARCHAR(100) NULL,
|
||||||
`file_size` INTEGER UNSIGNED NULL,
|
`file_size` INTEGER UNSIGNED NULL,
|
||||||
@@ -599,7 +572,6 @@ CREATE TABLE `users` (
|
|||||||
`totp_secret` VARCHAR(255) NULL,
|
`totp_secret` VARCHAR(255) NULL,
|
||||||
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
`totp_backup_codes` TEXT NULL,
|
`totp_backup_codes` TEXT NULL,
|
||||||
`totp_last_used_counter` INTEGER NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `username`(`username`),
|
UNIQUE INDEX `username`(`username`),
|
||||||
UNIQUE INDEX `email`(`email`),
|
UNIQUE INDEX `email`(`email`),
|
||||||
@@ -625,28 +597,12 @@ CREATE TABLE `vehicles` (
|
|||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) 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
|
-- AddForeignKey
|
||||||
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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;
|
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
|
-- 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;
|
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -718,3 +674,4 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
|
|||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
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,2 @@
|
|||||||
|
-- Add billing_text column to invoices table
|
||||||
|
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;
|
||||||
3
prisma/migrations/20260324_add_offer_lock/migration.sql
Normal file
3
prisma/migrations/20260324_add_offer_lock/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 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`;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 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`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 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
prisma/migrations/20260327_add_dark_logo/migration.sql
Normal file
1
prisma/migrations/20260327_add_dark_logo/migration.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `company_settings`
|
||||||
|
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `company_settings`
|
||||||
|
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;
|
||||||
13
prisma/migrations/20260327_add_system_settings/migration.sql
Normal file
13
prisma/migrations/20260327_add_system_settings/migration.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- System settings columns on company_settings
|
||||||
|
ALTER TABLE `company_settings`
|
||||||
|
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
|
||||||
|
ADD COLUMN `break_duration_short` INT DEFAULT 15,
|
||||||
|
ADD COLUMN `break_duration_long` INT DEFAULT 30,
|
||||||
|
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
|
||||||
|
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
|
||||||
|
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
|
||||||
|
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
|
||||||
|
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
|
||||||
|
ADD COLUMN `available_currencies` LONGTEXT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Create new unified permission
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
|
||||||
|
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
|
||||||
|
|
||||||
|
-- Grant to all roles that had any of the old 3
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
|
||||||
|
|
||||||
|
-- Clean up old role_permissions
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
|
||||||
|
|
||||||
|
-- Remove old permissions
|
||||||
|
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- 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`;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
|
||||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- 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`;
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- 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
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (i.e. Git)
|
||||||
provider = "mysql"
|
provider = "mysql"
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ model attendance_project_logs {
|
|||||||
hours Int? @db.UnsignedInt
|
hours Int? @db.UnsignedInt
|
||||||
minutes Int? @db.UnsignedInt
|
minutes Int? @db.UnsignedInt
|
||||||
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
|
|
||||||
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
|
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
|
||||||
@@index([project_id], map: "idx_project_id")
|
@@index([project_id], map: "idx_project_id")
|
||||||
@@ -101,18 +100,18 @@ model company_settings {
|
|||||||
vat_id String? @db.VarChar(50)
|
vat_id String? @db.VarChar(50)
|
||||||
custom_fields String? @db.LongText
|
custom_fields String? @db.LongText
|
||||||
logo_data Bytes?
|
logo_data Bytes?
|
||||||
logo_data_dark Bytes? @db.MediumBlob
|
logo_data_dark Bytes?
|
||||||
quotation_prefix String? @db.VarChar(20)
|
quotation_prefix String? @db.VarChar(20)
|
||||||
default_currency String? @default("CZK") @db.VarChar(10)
|
default_currency String? @default("CZK") @db.VarChar(10)
|
||||||
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
uuid String? @unique @db.VarChar(36)
|
uuid String? @db.VarChar(36)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
is_deleted Boolean? @default(false)
|
is_deleted Boolean? @default(false)
|
||||||
sync_version Int? @default(0)
|
sync_version Int? @default(0)
|
||||||
order_type_code String? @db.VarChar(10)
|
order_type_code String? @db.VarChar(10)
|
||||||
invoice_type_code String? @db.VarChar(10)
|
invoice_type_code String? @db.VarChar(10)
|
||||||
require_2fa Boolean @default(false)
|
require_2fa Boolean @default(false)
|
||||||
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
|
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
|
||||||
break_duration_short Int? @default(15)
|
break_duration_short Int? @default(15)
|
||||||
break_duration_long Int? @default(30)
|
break_duration_long Int? @default(30)
|
||||||
clock_rounding_minutes Int? @default(15)
|
clock_rounding_minutes Int? @default(15)
|
||||||
@@ -128,12 +127,6 @@ model company_settings {
|
|||||||
offer_number_pattern String? @db.VarChar(100)
|
offer_number_pattern String? @db.VarChar(100)
|
||||||
order_number_pattern String? @db.VarChar(100)
|
order_number_pattern String? @db.VarChar(100)
|
||||||
invoice_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 {
|
model customers {
|
||||||
@@ -159,9 +152,8 @@ model customers {
|
|||||||
model invoice_items {
|
model invoice_items {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
invoice_id Int
|
invoice_id Int
|
||||||
description String? @db.VarChar(500)
|
description String? @db.VarChar(500)
|
||||||
item_description String? @db.Text
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
@@ -173,7 +165,7 @@ model invoice_items {
|
|||||||
|
|
||||||
model invoices {
|
model invoices {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
|
invoice_number String? @db.VarChar(50)
|
||||||
order_id Int?
|
order_id Int?
|
||||||
customer_id Int?
|
customer_id Int?
|
||||||
status String? @default("issued") @db.VarChar(30)
|
status String? @default("issued") @db.VarChar(30)
|
||||||
@@ -204,7 +196,6 @@ model invoices {
|
|||||||
@@index([customer_id], map: "customer_id")
|
@@index([customer_id], map: "customer_id")
|
||||||
@@index([due_date], map: "idx_invoices_due_date")
|
@@index([due_date], map: "idx_invoices_due_date")
|
||||||
@@index([status, issue_date], map: "idx_invoices_status_issue")
|
@@index([status, issue_date], map: "idx_invoices_status_issue")
|
||||||
@@index([status, due_date], map: "idx_invoices_status_due")
|
|
||||||
@@index([order_id], map: "order_id")
|
@@index([order_id], map: "order_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +253,6 @@ model number_sequences {
|
|||||||
type String? @db.VarChar(50)
|
type String? @db.VarChar(50)
|
||||||
year Int?
|
year Int?
|
||||||
last_number Int? @default(0)
|
last_number Int? @default(0)
|
||||||
|
|
||||||
@@unique([type, year], map: "idx_number_sequences_type_year")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model order_items {
|
model order_items {
|
||||||
@@ -297,7 +286,7 @@ model order_sections {
|
|||||||
|
|
||||||
model orders {
|
model orders {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
|
order_number String? @db.VarChar(50)
|
||||||
customer_order_number String? @db.VarChar(100)
|
customer_order_number String? @db.VarChar(100)
|
||||||
attachment_data Bytes?
|
attachment_data Bytes?
|
||||||
attachment_name String? @db.VarChar(255)
|
attachment_name String? @db.VarChar(255)
|
||||||
@@ -349,7 +338,7 @@ model project_notes {
|
|||||||
|
|
||||||
model projects {
|
model projects {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
project_number String? @unique @db.VarChar(50)
|
project_number String? @db.VarChar(50)
|
||||||
name String? @db.VarChar(255)
|
name String? @db.VarChar(255)
|
||||||
customer_id Int?
|
customer_id Int?
|
||||||
responsible_user_id Int?
|
responsible_user_id Int?
|
||||||
@@ -361,10 +350,7 @@ model projects {
|
|||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
attendance_project_logs attendance_project_logs[]
|
|
||||||
project_notes project_notes[]
|
project_notes project_notes[]
|
||||||
sklad_issues sklad_issues[]
|
|
||||||
sklad_reservations sklad_reservations[]
|
|
||||||
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
|
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
|
||||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
|
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
|
||||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
|
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
|
||||||
@@ -386,7 +372,10 @@ model quotation_items {
|
|||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
|
uuid String? @db.VarChar(36)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
@@ -394,8 +383,8 @@ model quotation_items {
|
|||||||
|
|
||||||
model quotations {
|
model quotations {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
quotation_number String? @unique @db.VarChar(50)
|
quotation_number String? @db.VarChar(50)
|
||||||
project_code String? @db.VarChar(100)
|
project_code String? @db.VarChar(50)
|
||||||
customer_id Int?
|
customer_id Int?
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
valid_until DateTime? @db.Date
|
valid_until DateTime? @db.Date
|
||||||
@@ -403,13 +392,17 @@ model quotations {
|
|||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
apply_vat Boolean? @default(true)
|
apply_vat Boolean? @default(true)
|
||||||
|
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||||
|
exchange_rate_date DateTime? @db.Date
|
||||||
order_id Int?
|
order_id Int?
|
||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
|
uuid String? @db.VarChar(36)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
sync_version Int? @default(0)
|
||||||
orders orders[]
|
orders orders[]
|
||||||
projects projects[]
|
projects projects[]
|
||||||
quotation_items quotation_items[]
|
quotation_items quotation_items[]
|
||||||
@@ -494,7 +487,11 @@ model scope_sections {
|
|||||||
title String? @db.VarChar(500)
|
title String? @db.VarChar(500)
|
||||||
title_cz String? @db.VarChar(500)
|
title_cz String? @db.VarChar(500)
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
|
content_editor_height Int?
|
||||||
|
uuid String? @db.VarChar(36)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
@@ -581,16 +578,12 @@ model users {
|
|||||||
totp_secret String? @db.VarChar(255)
|
totp_secret String? @db.VarChar(255)
|
||||||
totp_enabled Boolean @default(false)
|
totp_enabled Boolean @default(false)
|
||||||
totp_backup_codes String? @db.Text
|
totp_backup_codes String? @db.Text
|
||||||
totp_last_used_counter Int?
|
|
||||||
attendance attendance[]
|
attendance attendance[]
|
||||||
leave_balances leave_balances[]
|
leave_balances leave_balances[]
|
||||||
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
|
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
|
||||||
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
|
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
|
||||||
projects projects[]
|
projects projects[]
|
||||||
trips trips[]
|
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")
|
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
|
||||||
|
|
||||||
@@index([is_active], map: "idx_users_is_active")
|
@@index([is_active], map: "idx_users_is_active")
|
||||||
@@ -631,9 +624,8 @@ model invoice_alert_log {
|
|||||||
invoice_id Int
|
invoice_id Int
|
||||||
alert_type String @db.VarChar(20) // "3days" or "due"
|
alert_type String @db.VarChar(20) // "3days" or "due"
|
||||||
sent_at DateTime @default(now()) @db.DateTime(0)
|
sent_at DateTime @default(now()) @db.DateTime(0)
|
||||||
created_at DateTime @default(now()) @db.DateTime(0)
|
|
||||||
|
|
||||||
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
|
@@unique([invoice_type, invoice_id, alert_type])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum received_invoices_status {
|
enum received_invoices_status {
|
||||||
@@ -648,272 +640,3 @@ enum attendance_leave_type {
|
|||||||
holiday
|
holiday
|
||||||
unpaid
|
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
469
prisma/seed.ts
@@ -1,469 +0,0 @@
|
|||||||
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());
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import { config } from "../config/env";
|
|
||||||
|
|
||||||
describe("auth service", () => {
|
|
||||||
describe("verifyAccessToken", () => {
|
|
||||||
it("returns null and logs error for invalid JWT", async () => {
|
|
||||||
const consoleSpy = vi
|
|
||||||
.spyOn(console, "error")
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const result = await verifyAccessToken("invalid-token");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(consoleSpy).toHaveBeenCalled();
|
|
||||||
expect(consoleSpy.mock.calls[0][0]).toMatch(/JWT verification error/);
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for expired JWT", async () => {
|
|
||||||
const consoleSpy = vi
|
|
||||||
.spyOn(console, "error")
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const expiredToken = jwt.sign(
|
|
||||||
{ sub: 1, username: "test", role: "user" },
|
|
||||||
config.jwt.secret,
|
|
||||||
{ expiresIn: -1 },
|
|
||||||
);
|
|
||||||
const result = await verifyAccessToken(expiredToken);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(consoleSpy).toHaveBeenCalled();
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hashToken", () => {
|
|
||||||
it("produces deterministic SHA-256 hex output", () => {
|
|
||||||
const t1 = hashToken("hello");
|
|
||||||
const t2 = hashToken("hello");
|
|
||||||
expect(t1).toBe(t2);
|
|
||||||
expect(t1).toMatch(/^[a-f0-9]{64}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces different hashes for different inputs", () => {
|
|
||||||
const t1 = hashToken("a");
|
|
||||||
const t2 = hashToken("b");
|
|
||||||
expect(t1).not.toBe(t2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
|
||||||
|
|
||||||
describe("UpdateCustomerSchema", () => {
|
|
||||||
it("rejects empty name", () => {
|
|
||||||
const result = UpdateCustomerSchema.safeParse({ name: "" });
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid name", () => {
|
|
||||||
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts partial updates without name", () => {
|
|
||||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { config } from "../config/env";
|
|
||||||
|
|
||||||
describe("env validation", () => {
|
|
||||||
it("has numeric port within valid range", () => {
|
|
||||||
expect(typeof config.port).toBe("number");
|
|
||||||
expect(config.port).toBeGreaterThan(0);
|
|
||||||
expect(config.port).toBeLessThanOrEqual(65535);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has JWT_SECRET defined", () => {
|
|
||||||
expect(config.jwt.secret).toBeTruthy();
|
|
||||||
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has TOTP_ENCRYPTION_KEY defined", () => {
|
|
||||||
expect(config.totp.encryptionKey).toBeTruthy();
|
|
||||||
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has positive JWT expiry values", () => {
|
|
||||||
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
|
|
||||||
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
|
|
||||||
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has positive maxUploadSize", () => {
|
|
||||||
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has DATABASE_URL defined", () => {
|
|
||||||
expect(config.db.url).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { toCzk, getRate } from "../services/exchange-rates";
|
|
||||||
|
|
||||||
// Mock global fetch
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch;
|
|
||||||
|
|
||||||
describe("exchange-rates", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toCzk", () => {
|
|
||||||
it("returns amount unchanged for CZK", async () => {
|
|
||||||
const result = await toCzk(123.45, "CZK");
|
|
||||||
expect(result).toBe(123.45);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws for unknown currency when API fails and no cache", async () => {
|
|
||||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
||||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(
|
|
||||||
/Nepodařilo se získat aktuální kurzy/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws for unknown currency even when API succeeds", async () => {
|
|
||||||
mockFetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts EUR using fetched rate", async () => {
|
|
||||||
mockFetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const result = await toCzk(100, "EUR");
|
|
||||||
expect(result).toBe(2500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getRate", () => {
|
|
||||||
it("returns 1 for CZK", async () => {
|
|
||||||
const result = await getRate("CZK");
|
|
||||||
expect(result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws for unknown currency", async () => {
|
|
||||||
mockFetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
|
||||||
|
|
||||||
describe("invoiceTotalWithVat", () => {
|
|
||||||
it("calculates subtotal without VAT when apply_vat is false", () => {
|
|
||||||
const inv = {
|
|
||||||
apply_vat: false,
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
currency: "CZK",
|
|
||||||
invoice_items: [
|
|
||||||
{
|
|
||||||
quantity: { toNumber: () => 2 },
|
|
||||||
unit_price: { toNumber: () => 100 },
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rounds each line VAT to 2 decimals before accumulation", () => {
|
|
||||||
// 3 items @ 33.33 with 21% VAT
|
|
||||||
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
|
|
||||||
// Total VAT = 7.00 * 3 = 21.00
|
|
||||||
// Subtotal = 33.33 * 3 = 99.99
|
|
||||||
// Total = 99.99 + 21.00 = 120.99
|
|
||||||
const inv = {
|
|
||||||
apply_vat: true,
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
currency: "CZK",
|
|
||||||
invoice_items: Array.from({ length: 3 }, () => ({
|
|
||||||
quantity: { toNumber: () => 1 },
|
|
||||||
unit_price: { toNumber: () => 33.33 },
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles null quantity and unit_price gracefully", () => {
|
|
||||||
const inv = {
|
|
||||||
apply_vat: true,
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
currency: "CZK",
|
|
||||||
invoice_items: [
|
|
||||||
{
|
|
||||||
quantity: { toNumber: () => 0 },
|
|
||||||
unit_price: { toNumber: () => 0 },
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { NasFileManager } from "../services/nas-file-manager";
|
|
||||||
|
|
||||||
describe("NasFileManager path traversal", () => {
|
|
||||||
const nas = new NasFileManager();
|
|
||||||
|
|
||||||
describe("deleteItem", () => {
|
|
||||||
it("rejects empty path", async () => {
|
|
||||||
const result = await nas.deleteItem("PRJ-001", "");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects root path /", async () => {
|
|
||||||
const result = await nas.deleteItem("PRJ-001", "/");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects current directory .", async () => {
|
|
||||||
const result = await nas.deleteItem("PRJ-001", ".");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects current directory ./", async () => {
|
|
||||||
const result = await nas.deleteItem("PRJ-001", "./");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects path traversal ..", async () => {
|
|
||||||
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
|
|
||||||
expect(result).toContain("Neplatná cesta");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("moveItem", () => {
|
|
||||||
it("rejects empty fromPath", async () => {
|
|
||||||
const result = await nas.moveItem("PRJ-001", "", "dest");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects root fromPath /", async () => {
|
|
||||||
const result = await nas.moveItem("PRJ-001", "/", "dest");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects current directory .", async () => {
|
|
||||||
const result = await nas.moveItem("PRJ-001", ".", "dest");
|
|
||||||
expect(result).toContain("kořenovou složku");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects path traversal in fromPath", async () => {
|
|
||||||
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
|
|
||||||
expect(result).toContain("Neplatná cesta");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +1,21 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateSharedNumber,
|
generateSharedNumber,
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
import prisma from "../config/database";
|
|
||||||
|
|
||||||
describe("generateSharedNumber", () => {
|
describe("generateSharedNumber", () => {
|
||||||
beforeEach(async () => {
|
it("returns correct format (YYtypeCode + 4 digits)", async () => {
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a non-empty string", async () => {
|
|
||||||
const num = await generateSharedNumber();
|
const num = await generateSharedNumber();
|
||||||
expect(typeof num).toBe("string");
|
const yy = String(new Date().getFullYear()).slice(-2);
|
||||||
expect(num.length).toBeGreaterThan(0);
|
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
|
||||||
});
|
|
||||||
|
|
||||||
it("increments on consecutive calls", async () => {
|
|
||||||
const num1 = await generateSharedNumber();
|
|
||||||
const num2 = await generateSharedNumber();
|
|
||||||
expect(num1).not.toBe(num2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("generateOfferNumber", () => {
|
describe("generateOfferNumber", () => {
|
||||||
beforeEach(async () => {
|
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a non-empty string", async () => {
|
|
||||||
const num = await generateOfferNumber();
|
const num = await generateOfferNumber();
|
||||||
expect(typeof num).toBe("string");
|
const year = new Date().getFullYear();
|
||||||
expect(num.length).toBeGreaterThan(0);
|
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
|
||||||
});
|
|
||||||
|
|
||||||
it("increments on consecutive calls", async () => {
|
|
||||||
const num1 = await generateOfferNumber();
|
|
||||||
const num2 = await generateOfferNumber();
|
|
||||||
expect(num1).not.toBe(num2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
|
||||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
|
||||||
|
|
||||||
describe("Zod NaN rejection", () => {
|
|
||||||
describe("CreateOrderSchema", () => {
|
|
||||||
it("rejects NaN string in quantity", () => {
|
|
||||||
const result = CreateOrderSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
items: [{ quantity: "not-a-number" }],
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects NaN string in unit_price", () => {
|
|
||||||
const result = CreateOrderSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
items: [{ unit_price: "abc" }],
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid numbers", () => {
|
|
||||||
const result = CreateOrderSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
items: [{ quantity: 2, unit_price: 100 }],
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CreateQuotationSchema", () => {
|
|
||||||
it("rejects NaN string in top-level vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: "bad",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: 21,
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects NaN string in item quantity", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
items: [{ quantity: "bad" }],
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,19 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import { AlertProvider } from "./context/AlertContext";
|
import { AlertProvider } from "./context/AlertContext";
|
||||||
import { queryClient } from "./lib/queryClient";
|
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
import AlertContainer from "./components/AlertContainer";
|
import AlertContainer from "./components/AlertContainer";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import "./variables.css";
|
import "./admin.css";
|
||||||
import "./base.css";
|
|
||||||
import "./forms.css";
|
|
||||||
import "./buttons.css";
|
|
||||||
import "./layout.css";
|
|
||||||
import "./components.css";
|
|
||||||
import "./tables.css";
|
|
||||||
import "./datepicker.css";
|
|
||||||
import "./filemanager.css";
|
|
||||||
import "./pagination.css";
|
|
||||||
import "./responsive.css";
|
|
||||||
import "./login.css";
|
import "./login.css";
|
||||||
import "./dashboard.css";
|
import "./dashboard.css";
|
||||||
import "./attendance.css";
|
import "./attendance.css";
|
||||||
import "./settings.css";
|
import "./settings.css";
|
||||||
import "./offers.css";
|
import "./offers.css";
|
||||||
import "./invoices.css";
|
import "./invoices.css";
|
||||||
import "./warehouse.css";
|
|
||||||
|
|
||||||
const Users = lazy(() => import("./pages/Users"));
|
const Users = lazy(() => import("./pages/Users"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||||
@@ -48,186 +35,76 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
|||||||
const Orders = lazy(() => import("./pages/Orders"));
|
const Orders = lazy(() => import("./pages/Orders"));
|
||||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||||
const Projects = lazy(() => import("./pages/Projects"));
|
const Projects = lazy(() => import("./pages/Projects"));
|
||||||
|
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
|
||||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||||
const Settings = lazy(() => import("./pages/Settings"));
|
const Settings = lazy(() => import("./pages/Settings"));
|
||||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
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() {
|
export default function AdminApp() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AlertProvider>
|
<AlertProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AlertContainer />
|
||||||
<AlertContainer />
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<Suspense
|
||||||
<Suspense
|
fallback={
|
||||||
fallback={
|
<div className="admin-loading">
|
||||||
<div className="admin-loading">
|
<div className="admin-spinner" />
|
||||||
<div className="admin-spinner" />
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
>
|
||||||
>
|
<Routes>
|
||||||
<Routes>
|
<Route path="login" element={<Login />} />
|
||||||
<Route path="login" element={<Login />} />
|
<Route element={<AdminLayout />}>
|
||||||
<Route element={<AdminLayout />}>
|
<Route index element={<Dashboard />} />
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="attendance" element={<Attendance />} />
|
||||||
<Route path="attendance" element={<Attendance />} />
|
<Route
|
||||||
<Route
|
path="attendance/history"
|
||||||
path="attendance/history"
|
element={<AttendanceHistory />}
|
||||||
element={<AttendanceHistory />}
|
/>
|
||||||
/>
|
<Route path="attendance/admin" element={<AttendanceAdmin />} />
|
||||||
<Route
|
<Route
|
||||||
path="attendance/admin"
|
path="attendance/balances"
|
||||||
element={<AttendanceAdmin />}
|
element={<AttendanceBalances />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="attendance/requests" element={<LeaveRequests />} />
|
||||||
path="attendance/balances"
|
<Route path="attendance/approval" element={<LeaveApproval />} />
|
||||||
element={<AttendanceBalances />}
|
<Route
|
||||||
/>
|
path="attendance/create"
|
||||||
<Route
|
element={<AttendanceCreate />}
|
||||||
path="attendance/requests"
|
/>
|
||||||
element={<LeaveRequests />}
|
<Route
|
||||||
/>
|
path="attendance/location/:id"
|
||||||
<Route
|
element={<AttendanceLocation />}
|
||||||
path="attendance/approval"
|
/>
|
||||||
element={<LeaveApproval />}
|
<Route path="trips" element={<Trips />} />
|
||||||
/>
|
<Route path="trips/history" element={<TripsHistory />} />
|
||||||
<Route
|
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||||
path="attendance/create"
|
<Route path="vehicles" element={<Vehicles />} />
|
||||||
element={<AttendanceCreate />}
|
<Route path="offers" element={<Offers />} />
|
||||||
/>
|
<Route path="offers/new" element={<OfferDetail />} />
|
||||||
<Route
|
<Route path="offers/:id" element={<OfferDetail />} />
|
||||||
path="attendance/location/:id"
|
<Route path="offers/customers" element={<OffersCustomers />} />
|
||||||
element={<AttendanceLocation />}
|
<Route path="offers/templates" element={<OffersTemplates />} />
|
||||||
/>
|
<Route path="orders" element={<Orders />} />
|
||||||
<Route path="trips" element={<Trips />} />
|
<Route path="orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="trips/history" element={<TripsHistory />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
<Route path="projects/new" element={<ProjectCreate />} />
|
||||||
<Route path="vehicles" element={<Vehicles />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="offers" element={<Offers />} />
|
<Route path="invoices" element={<Invoices />} />
|
||||||
<Route path="offers/new" element={<OfferDetail />} />
|
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||||
<Route path="offers/:id" element={<OfferDetail />} />
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
<Route
|
<Route path="settings" element={<Settings />} />
|
||||||
path="offers/customers"
|
<Route path="audit-log" element={<AuditLog />} />
|
||||||
element={<OffersCustomers />}
|
</Route>
|
||||||
/>
|
<Route path="*" element={<NotFound />} />
|
||||||
<Route
|
</Routes>
|
||||||
path="offers/templates"
|
</Suspense>
|
||||||
element={<OffersTemplates />}
|
</ErrorBoundary>
|
||||||
/>
|
|
||||||
<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>
|
</AlertProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
3228
src/admin/admin.css
Normal file
3228
src/admin/admin.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -318,14 +318,12 @@
|
|||||||
|
|
||||||
/* Leave Type Badges */
|
/* Leave Type Badges */
|
||||||
.attendance-leave-badge {
|
.attendance-leave-badge {
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
padding: 0.25rem 0.5rem;
|
||||||
justify-content: center;
|
|
||||||
padding: 0.25rem 0.55rem;
|
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
margin-right: 0.25rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Reset & Base
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
min-height: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
overflow-x: hidden;
|
|
||||||
overscroll-behavior-x: none;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
transition:
|
|
||||||
background-color 0.3s ease,
|
|
||||||
color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar,
|
|
||||||
.admin-header,
|
|
||||||
.admin-card,
|
|
||||||
.admin-modal {
|
|
||||||
transition:
|
|
||||||
background-color 0.3s ease,
|
|
||||||
color 0.3s ease,
|
|
||||||
border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
overflow-x: hidden;
|
|
||||||
touch-action: pan-y pinch-zoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: clamp(2rem, 4vw, 3rem);
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Base / Utilities
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.text-warning {
|
|
||||||
color: var(--warning) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
|
||||||
color: var(--danger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: var(--success) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-tertiary {
|
|
||||||
color: var(--text-tertiary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accent {
|
|
||||||
color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fw-600 {
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-accent {
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-accent:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout utilities */
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.flex-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.flex-row-gap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.flex-between {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spacing utilities */
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.mb-4 {
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
.mb-6 {
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
.mt-2 {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.mt-6 {
|
|
||||||
margin-top: var(--space-6);
|
|
||||||
}
|
|
||||||
.gap-2 {
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.gap-4 {
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
.gap-5 {
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography utilities */
|
|
||||||
.fw-500 {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spinner variant */
|
|
||||||
.admin-spinner-sm {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Monospace for data values (times, dates, numbers, IDs) */
|
|
||||||
.admin-mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.875em;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Drag handle */
|
|
||||||
.admin-drag-handle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: grab;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0;
|
|
||||||
transition:
|
|
||||||
color 0.15s,
|
|
||||||
background 0.15s;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-drag-handle:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-drag-handle:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error stack (DEV only) */
|
|
||||||
.admin-error-stack {
|
|
||||||
max-width: 600px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--danger-color);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyboard shortcut badge */
|
|
||||||
.admin-kbd {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 7px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading & Animations */
|
|
||||||
.admin-spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid var(--accent-color);
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 256px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate(30px, -30px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Additional Utilities ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Font sizes */
|
|
||||||
.text-xs {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.text-sm {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.text-md {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
.text-base {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Width utilities */
|
|
||||||
.w-full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.max-w-xs {
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
.max-w-sm {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Whitespace */
|
|
||||||
.whitespace-nowrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional gaps */
|
|
||||||
.gap-1 {
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.gap-3 {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.gap-6 {
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional margins */
|
|
||||||
.mb-1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.mb-3 {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.mt-1 {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
.mt-3 {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Display */
|
|
||||||
.inline-flex {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prefers reduced motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-spinner {
|
|
||||||
animation-duration: 0.8s !important;
|
|
||||||
animation-iteration-count: infinite !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Buttons
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 550;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent buttons from growing when text changes to spinner + loading text */
|
|
||||||
.admin-modal-footer .admin-btn {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
.admin-modal-footer .admin-btn .admin-spinner {
|
|
||||||
margin: -2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-sm {
|
|
||||||
padding: 6px 11px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-primary {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn .admin-spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-primary .admin-spinner {
|
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-secondary .admin-spinner {
|
|
||||||
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
|
|
||||||
border-top-color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-secondary {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-secondary:hover:not(:disabled) {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon.accent {
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon.accent:hover {
|
|
||||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon.danger {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon.danger:hover {
|
|
||||||
background: var(--danger-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch targets - min 44px on mobile */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-btn {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-sm {
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn-icon {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef, useMemo } from "react";
|
||||||
import DatePicker, { registerLocale } from "react-datepicker";
|
import DatePicker, { registerLocale } from "react-datepicker";
|
||||||
import { cs } from "date-fns/locale";
|
import { cs } from "date-fns/locale";
|
||||||
import { parse, format } from "date-fns";
|
import { parse, format } from "date-fns";
|
||||||
@@ -75,19 +75,6 @@ function NativeInput({
|
|||||||
disabled,
|
disabled,
|
||||||
}: NativeInputProps) {
|
}: NativeInputProps) {
|
||||||
const type = modeToInputType[mode] || "date";
|
const type = modeToInputType[mode] || "date";
|
||||||
// For time inputs, min/max must be in HH:mm format, not date format
|
|
||||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
|
||||||
if (!val) return undefined;
|
|
||||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
|
||||||
// otherwise it's not a valid time min/max — return undefined
|
|
||||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
|
||||||
if (val.includes(":")) return val.substring(0, 5);
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
const minProp =
|
|
||||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
|
||||||
const maxProp =
|
|
||||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -97,14 +84,14 @@ function NativeInput({
|
|||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={minProp}
|
min={minDate || undefined}
|
||||||
max={maxProp}
|
max={maxDate || undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminDatePickerProps {
|
interface AdminDatePickerProps {
|
||||||
mode?: "date" | "month" | "time";
|
mode?: "date" | "month" | "datetime" | "time";
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
minDate?: string;
|
minDate?: string;
|
||||||
@@ -124,7 +111,7 @@ export default function AdminDatePicker({
|
|||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: AdminDatePickerProps) {
|
}: AdminDatePickerProps) {
|
||||||
const useNative = isTouchDevice();
|
const useNative = useMemo(() => isTouchDevice(), []);
|
||||||
|
|
||||||
if (useNative) {
|
if (useNative) {
|
||||||
return (
|
return (
|
||||||
@@ -178,20 +165,17 @@ export default function AdminDatePicker({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const customInput = (
|
|
||||||
<CustomInput
|
|
||||||
required={required}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
selected: toDate(value),
|
selected: toDate(value),
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
locale: "cs",
|
locale: "cs",
|
||||||
customInput,
|
customInput: (
|
||||||
placeholderText: placeholder,
|
<CustomInput
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
),
|
||||||
minDate: parseMinMax(minDate),
|
minDate: parseMinMax(minDate),
|
||||||
maxDate: parseMinMax(maxDate),
|
maxDate: parseMinMax(maxDate),
|
||||||
popperPlacement: "bottom-start" as const,
|
popperPlacement: "bottom-start" as const,
|
||||||
|
|||||||
@@ -8,13 +8,34 @@ import {
|
|||||||
getLeaveTypeName,
|
getLeaveTypeName,
|
||||||
getLeaveTypeBadgeClass,
|
getLeaveTypeBadgeClass,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
|
||||||
|
|
||||||
interface AttendanceRecord extends HookAttendanceRecord {
|
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;
|
||||||
arrival_lat?: number | string | null;
|
arrival_lat?: number | string | null;
|
||||||
arrival_lng?: number | string | null;
|
arrival_lng?: number | string | null;
|
||||||
departure_lat?: number | string | null;
|
departure_lat?: number | string | null;
|
||||||
departure_lng?: number | string | null;
|
departure_lng?: number | string | null;
|
||||||
|
project_name?: string;
|
||||||
|
project_logs?: ProjectLog[];
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttendanceShiftTableProps {
|
interface AttendanceShiftTableProps {
|
||||||
@@ -30,7 +51,7 @@ function formatBreak(record: AttendanceRecord): string {
|
|||||||
if (record.break_start) {
|
if (record.break_start) {
|
||||||
return `${formatTime(record.break_start)} - ?`;
|
return `${formatTime(record.break_start)} - ?`;
|
||||||
}
|
}
|
||||||
return "—";
|
return "\u2014";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||||
@@ -43,30 +64,21 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
isActive = false;
|
isActive = false;
|
||||||
let durationValid = true;
|
|
||||||
if (log.hours !== null && log.hours !== undefined) {
|
if (log.hours !== null && log.hours !== undefined) {
|
||||||
h = parseInt(String(log.hours)) || 0;
|
h = parseInt(String(log.hours)) || 0;
|
||||||
m = parseInt(String(log.minutes)) || 0;
|
m = parseInt(String(log.minutes)) || 0;
|
||||||
} else {
|
} else {
|
||||||
isActive = !log.ended_at;
|
isActive = !log.ended_at;
|
||||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||||
const start = log.started_at ? new Date(log.started_at) : null;
|
const mins = Math.floor(
|
||||||
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||||
const mins = Math.max(
|
);
|
||||||
0,
|
h = Math.floor(mins / 60);
|
||||||
Math.floor((end.getTime() - start.getTime()) / 60000),
|
m = mins % 60;
|
||||||
);
|
|
||||||
h = Math.floor(mins / 60);
|
|
||||||
m = mins % 60;
|
|
||||||
} else {
|
|
||||||
durationValid = false;
|
|
||||||
h = 0;
|
|
||||||
m = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={log.id ?? i}
|
key={log.id || i}
|
||||||
className="admin-badge"
|
className="admin-badge"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "0.7rem",
|
||||||
@@ -74,10 +86,8 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
background: isActive ? "var(--accent-light)" : undefined,
|
background: isActive ? "var(--accent-light)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{log.project_name || `#${log.project_id}`}{" "}
|
{log.project_name || `#${log.project_id}`} ({h}:
|
||||||
{durationValid
|
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
|
||||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
|
||||||
: "—"}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -94,7 +104,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
return "\u2014";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AttendanceShiftTable({
|
export default function AttendanceShiftTable({
|
||||||
@@ -133,12 +143,8 @@ export default function AttendanceShiftTable({
|
|||||||
const leaveType = record.leave_type || "work";
|
const leaveType = record.leave_type || "work";
|
||||||
const isLeave = leaveType !== "work";
|
const isLeave = leaveType !== "work";
|
||||||
const workMinutes = isLeave
|
const workMinutes = isLeave
|
||||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
? (Number(record.leave_hours) || 8) * 60
|
||||||
60
|
: calculateWorkMinutes(record);
|
||||||
: calculateWorkMinutes({
|
|
||||||
...record,
|
|
||||||
notes: record.notes ?? undefined,
|
|
||||||
});
|
|
||||||
const hasLocation =
|
const hasLocation =
|
||||||
(record.arrival_lat && record.arrival_lng) ||
|
(record.arrival_lat && record.arrival_lng) ||
|
||||||
(record.departure_lat && record.departure_lng);
|
(record.departure_lat && record.departure_lng);
|
||||||
@@ -155,16 +161,18 @@ export default function AttendanceShiftTable({
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{isLeave ? "—" : formatBreak(record)}
|
{isLeave ? "\u2014" : formatBreak(record)}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
{workMinutes > 0
|
||||||
|
? `${formatMinutes(workMinutes)} h`
|
||||||
|
: "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td>{renderProjectCell(record)}</td>
|
<td>{renderProjectCell(record)}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -175,10 +183,10 @@ export default function AttendanceShiftTable({
|
|||||||
title="Zobrazit polohu"
|
title="Zobrazit polohu"
|
||||||
aria-label="Zobrazit polohu"
|
aria-label="Zobrazit polohu"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{"📍"}</span>
|
{"\uD83D\uDCCD"}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"\u2014"
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BulkAttendanceModalProps {
|
interface BulkAttendanceModalProps {
|
||||||
isOpen: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
form: BulkAttendanceForm;
|
form: BulkAttendanceForm;
|
||||||
setForm: (form: BulkAttendanceForm) => void;
|
setForm: (form: BulkAttendanceForm) => void;
|
||||||
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkAttendanceModal({
|
export default function BulkAttendanceModal({
|
||||||
isOpen,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
|
|||||||
toggleUser,
|
toggleUser,
|
||||||
toggleAllUsers,
|
toggleAllUsers,
|
||||||
}: BulkAttendanceModalProps) {
|
}: BulkAttendanceModalProps) {
|
||||||
useModalLock(isOpen);
|
useModalLock(show);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -57,21 +57,13 @@ export default function BulkAttendanceModal({
|
|||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal admin-modal-lg"
|
className="admin-modal admin-modal-lg"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="bulk-attendance-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
<div className="admin-modal-header">
|
||||||
<h2
|
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||||
id="bulk-attendance-modal-title"
|
|
||||||
className="admin-modal-title"
|
|
||||||
>
|
|
||||||
Vyplnit docházku za měsíc
|
|
||||||
</h2>
|
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
@@ -14,71 +14,6 @@ interface ConfirmModalProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmIcon({ type }: { type: string }) {
|
|
||||||
switch (type) {
|
|
||||||
case "danger":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case "info":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case "warning":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfirmModal({
|
export default function ConfirmModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -91,19 +26,6 @@ export default function ConfirmModal({
|
|||||||
confirmVariant,
|
confirmVariant,
|
||||||
loading,
|
loading,
|
||||||
}: ConfirmModalProps) {
|
}: 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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -117,9 +39,6 @@ export default function ConfirmModal({
|
|||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal admin-confirm-modal"
|
className="admin-modal admin-confirm-modal"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="confirm-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
@@ -127,11 +46,20 @@ export default function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div className="admin-modal-body admin-confirm-content">
|
<div className="admin-modal-body admin-confirm-content">
|
||||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||||
<ConfirmIcon type={type} />
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
<h2 className="admin-confirm-title">{title}</h2>
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className="admin-confirm-message">{message}</p>
|
<p className="admin-confirm-message">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
type CSSProperties,
|
|
||||||
type ReactNode,
|
|
||||||
isValidElement,
|
|
||||||
cloneElement,
|
|
||||||
useId,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
@@ -21,22 +15,13 @@ export default function FormField({
|
|||||||
required,
|
required,
|
||||||
style,
|
style,
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
const generatedId = useId();
|
|
||||||
const childProps = isValidElement(children)
|
|
||||||
? (children.props as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const childId = childProps?.id ? String(childProps.id) : generatedId;
|
|
||||||
const childWithId = isValidElement(children)
|
|
||||||
? cloneElement(children, { id: childId } as React.Attributes)
|
|
||||||
: children;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-form-group" style={style}>
|
<div className="admin-form-group" style={style}>
|
||||||
<label className="admin-form-label" htmlFor={childId}>
|
<label className="admin-form-label">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="admin-form-required"> *</span>}
|
{required && <span className="admin-form-required"> *</span>}
|
||||||
</label>
|
</label>
|
||||||
{childWithId}
|
{children}
|
||||||
{error && <span className="admin-form-error">{error}</span>}
|
{error && <span className="admin-form-error">{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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,396 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { useAlert } from "../context/AlertContext";
|
|
||||||
|
|
||||||
interface ConfirmationItem {
|
|
||||||
description: string;
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
unit_price: number;
|
|
||||||
is_included_in_total: boolean;
|
|
||||||
vat_rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderConfirmationModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onGenerate: (
|
|
||||||
lang: string,
|
|
||||||
applyVat: boolean,
|
|
||||||
items?: ConfirmationItem[],
|
|
||||||
) => Promise<void>;
|
|
||||||
initialItems: ConfirmationItem[];
|
|
||||||
orderNumber: string;
|
|
||||||
defaultVatRate: number;
|
|
||||||
applyVat: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrderConfirmationModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onGenerate,
|
|
||||||
initialItems,
|
|
||||||
orderNumber,
|
|
||||||
defaultVatRate,
|
|
||||||
applyVat,
|
|
||||||
}: OrderConfirmationModalProps) {
|
|
||||||
const alert = useAlert();
|
|
||||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
|
||||||
const [lang, setLang] = useState<string>("cs");
|
|
||||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
|
||||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleUseExisting = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onGenerate(lang, applyVatState, undefined);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Chyba při generování potvrzení:", err);
|
|
||||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setStep("choose");
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditGenerate = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await onGenerate(lang, applyVatState, items);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Chyba při generování potvrzení:", err);
|
|
||||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setStep("choose");
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateItem = useCallback(
|
|
||||||
(
|
|
||||||
index: number,
|
|
||||||
field: keyof ConfirmationItem,
|
|
||||||
value: string | number | boolean,
|
|
||||||
) => {
|
|
||||||
setItems((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
next[index] = { ...next[index], [field]: value };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeItem = useCallback((index: number) => {
|
|
||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addItem = useCallback(() => {
|
|
||||||
setItems((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
description: "",
|
|
||||||
quantity: 1,
|
|
||||||
unit: "ks",
|
|
||||||
unit_price: 0,
|
|
||||||
is_included_in_total: true,
|
|
||||||
vat_rate: defaultVatRate,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, [defaultVatRate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
className="admin-modal-overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
|
||||||
<motion.div
|
|
||||||
className={
|
|
||||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
|
||||||
}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="order-confirmation-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<div className="admin-modal-header">
|
|
||||||
<h2
|
|
||||||
id="order-confirmation-modal-title"
|
|
||||||
className="admin-modal-title"
|
|
||||||
>
|
|
||||||
Potvrzení objednávky {orderNumber}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-modal-body">
|
|
||||||
{step === "choose" ? (
|
|
||||||
<div className="admin-form">
|
|
||||||
<div className="admin-form-group">
|
|
||||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
|
||||||
<div className="flex-row gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLang("cs")}
|
|
||||||
className={
|
|
||||||
lang === "cs"
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Čeština
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLang("en")}
|
|
||||||
className={
|
|
||||||
lang === "en"
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
|
||||||
<label className="admin-form-label">DPH</label>
|
|
||||||
<div className="flex-row gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setApplyVatState(true)}
|
|
||||||
className={
|
|
||||||
applyVatState
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
S DPH
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setApplyVatState(false)}
|
|
||||||
className={
|
|
||||||
!applyVatState
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Bez DPH
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
|
||||||
<label className="admin-form-label">Obsah potvrzení</label>
|
|
||||||
<p
|
|
||||||
className="text-secondary"
|
|
||||||
style={{ marginBottom: "0.75rem" }}
|
|
||||||
>
|
|
||||||
Jak chcete připravit potvrzení objednávky?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleUseExisting}
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-primary w-full"
|
|
||||||
style={{ marginBottom: "0.5rem" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
|
||||||
Generuji...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Použít položky z objednávky"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setItems(initialItems.length > 0 ? initialItems : []);
|
|
||||||
setStep("edit");
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
|
||||||
>
|
|
||||||
Upravit položky
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-form">
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Popis</th>
|
|
||||||
<th>Mn.</th>
|
|
||||||
<th>Jedn.</th>
|
|
||||||
<th>Cena</th>
|
|
||||||
<th>%DPH</th>
|
|
||||||
<th style={{ width: "40px" }} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "description", e.target.value)
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ minWidth: "200px" }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(
|
|
||||||
i,
|
|
||||||
"quantity",
|
|
||||||
Number(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "80px" }}
|
|
||||||
step="0.001"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.unit}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "unit", e.target.value)
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "60px" }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.unit_price}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(
|
|
||||||
i,
|
|
||||||
"unit_price",
|
|
||||||
Number(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "100px" }}
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.vat_rate}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(
|
|
||||||
i,
|
|
||||||
"vat_rate",
|
|
||||||
Number(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "70px" }}
|
|
||||||
step="1"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => removeItem(i)}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Odstranit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={addItem}
|
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
|
||||||
+ Přidat položku
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-modal-footer">
|
|
||||||
{step === "edit" && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStep("choose")}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Zpět
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleEditGenerate}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
disabled={loading || items.length === 0}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
|
||||||
Generuji...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Vygenerovat PDF"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === "choose" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Zrušit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -36,18 +36,13 @@ export default function Pagination({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="admin-pagination">
|
||||||
className="admin-pagination"
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Stránkování"
|
|
||||||
>
|
|
||||||
<div className="admin-pagination-info">{total} záznamů</div>
|
<div className="admin-pagination-info">{total} záznamů</div>
|
||||||
<div className="admin-pagination-controls">
|
<div className="admin-pagination-controls">
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
className="admin-pagination-page"
|
className="admin-pagination-page"
|
||||||
aria-label="Předchozí stránka"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -70,8 +65,6 @@ export default function Pagination({
|
|||||||
key={p}
|
key={p}
|
||||||
onClick={() => onPageChange(p)}
|
onClick={() => onPageChange(p)}
|
||||||
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
||||||
aria-label={`Stránka ${p}`}
|
|
||||||
aria-current={p === page ? "page" : undefined}
|
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +74,6 @@ export default function Pagination({
|
|||||||
disabled={page >= total_pages}
|
disabled={page >= total_pages}
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
className="admin-pagination-page"
|
className="admin-pagination-page"
|
||||||
aria-label="Další stránka"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { projectFilesOptions } from "../lib/queries/projects";
|
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import ConfirmModal from "./ConfirmModal";
|
import ConfirmModal from "./ConfirmModal";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
@@ -198,11 +196,13 @@ export default function ProjectFileManager({
|
|||||||
hasNasFolder,
|
hasNasFolder,
|
||||||
}: ProjectFileManagerProps) {
|
}: ProjectFileManagerProps) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isCancelling = useRef(false);
|
|
||||||
|
|
||||||
|
const [items, setItems] = useState<FileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentPath, setCurrentPath] = useState("");
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
|
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
|
||||||
|
const [fullPath, setFullPath] = useState("");
|
||||||
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -216,25 +216,59 @@ export default function ProjectFileManager({
|
|||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = hasPermission("projects.files");
|
const canManage = hasPermission("projects.files");
|
||||||
|
|
||||||
const {
|
const fetchFiles = useCallback(
|
||||||
data: filesData,
|
async (path = "", options: { ignore?: boolean } = {}) => {
|
||||||
isPending: filesLoading,
|
setLoading(true);
|
||||||
error: filesError,
|
setErrorMessage(null);
|
||||||
} = useQuery(projectFilesOptions(projectId, currentPath));
|
try {
|
||||||
const items = filesData?.items ?? [];
|
const params = new URLSearchParams({ project_id: String(projectId) });
|
||||||
const breadcrumb = filesData?.breadcrumb ?? [""];
|
if (path) {
|
||||||
const fullPath = filesData?.full_path ?? "";
|
params.set("path", path);
|
||||||
const errorMessage = filesError
|
}
|
||||||
? filesError.message || "Nepodařilo se načíst soubory"
|
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
|
||||||
: null;
|
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 navigateTo = (path: string) => {
|
const navigateTo = (path: string) => {
|
||||||
setNewFolderMode(false);
|
setNewFolderMode(false);
|
||||||
setRenamingItem(null);
|
setRenamingItem(null);
|
||||||
setCurrentPath(path);
|
fetchFiles(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBreadcrumbClick = (index: number) => {
|
const handleBreadcrumbClick = (index: number) => {
|
||||||
@@ -297,9 +331,7 @@ export default function ProjectFileManager({
|
|||||||
? "Soubor byl nahrán"
|
? "Soubor byl nahrán"
|
||||||
: `Nahráno ${successCount} souborů`;
|
: `Nahráno ${successCount} souborů`;
|
||||||
alert.success(msg);
|
alert.success(msg);
|
||||||
queryClient.invalidateQueries({
|
fetchFiles(currentPath);
|
||||||
queryKey: ["projects", String(projectId), "files"],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (errorMsg) {
|
if (errorMsg) {
|
||||||
alert.error(errorMsg);
|
alert.error(errorMsg);
|
||||||
@@ -350,9 +382,7 @@ export default function ProjectFileManager({
|
|||||||
alert.success("Složka byla vytvořena");
|
alert.success("Složka byla vytvořena");
|
||||||
setNewFolderMode(false);
|
setNewFolderMode(false);
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
queryClient.invalidateQueries({
|
fetchFiles(currentPath);
|
||||||
queryKey: ["projects", String(projectId), "files"],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||||
}
|
}
|
||||||
@@ -413,9 +443,7 @@ export default function ProjectFileManager({
|
|||||||
? "Složka byla smazána"
|
? "Složka byla smazána"
|
||||||
: "Soubor byl smazán",
|
: "Soubor byl smazán",
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({
|
fetchFiles(currentPath);
|
||||||
queryKey: ["projects", String(projectId), "files"],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se smazat");
|
alert.error(data.error || "Nepodařilo se smazat");
|
||||||
}
|
}
|
||||||
@@ -450,9 +478,7 @@ export default function ProjectFileManager({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert.success("Přejmenováno");
|
alert.success("Přejmenováno");
|
||||||
queryClient.invalidateQueries({
|
fetchFiles(currentPath);
|
||||||
queryKey: ["projects", String(projectId), "files"],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||||
}
|
}
|
||||||
@@ -468,10 +494,31 @@ export default function ProjectFileManager({
|
|||||||
setRenameValue(item.name);
|
setRenameValue(item.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
if (loading && items.length === 0 && !errorMessage) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-card">
|
||||||
<div className="admin-spinner" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -662,7 +709,7 @@ export default function ProjectFileManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length === 0 && !filesLoading ? (
|
{items.length === 0 && !loading ? (
|
||||||
<div className="fm-empty">
|
<div className="fm-empty">
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
@@ -721,26 +768,10 @@ export default function ProjectFileManager({
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") handleRename(item);
|
||||||
e.preventDefault();
|
if (e.key === "Escape") setRenamingItem(null);
|
||||||
handleRename(item);
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
isCancelling.current = true;
|
|
||||||
setRenamingItem(null);
|
|
||||||
setRenameValue(item.name);
|
|
||||||
setTimeout(() => {
|
|
||||||
isCancelling.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (isCancelling.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleRename(item);
|
|
||||||
}}
|
}}
|
||||||
|
onBlur={() => handleRename(item)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileNameCell
|
<FileNameCell
|
||||||
|
|||||||
@@ -1,7 +1,66 @@
|
|||||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
import { useMemo, useRef, useCallback } from "react";
|
||||||
import ReactQuill from "react-quill-new";
|
import ReactQuill from "react-quill-new";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
|
const Quill = ReactQuill.Quill;
|
||||||
|
|
||||||
|
if (!(Quill as any).__bohaRegistered) {
|
||||||
|
const Font = Quill.import("attributors/class/font") as any;
|
||||||
|
Font.whitelist = [
|
||||||
|
"arial",
|
||||||
|
"tahoma",
|
||||||
|
"verdana",
|
||||||
|
"georgia",
|
||||||
|
"times-new-roman",
|
||||||
|
"courier-new",
|
||||||
|
"trebuchet-ms",
|
||||||
|
"impact",
|
||||||
|
"comic-sans-ms",
|
||||||
|
"lucida-console",
|
||||||
|
"palatino-linotype",
|
||||||
|
"garamond",
|
||||||
|
];
|
||||||
|
Quill.register(Font, true);
|
||||||
|
|
||||||
|
const SizeStyle = Quill.import("attributors/style/size") as any;
|
||||||
|
SizeStyle.whitelist = [
|
||||||
|
"8px",
|
||||||
|
"9px",
|
||||||
|
"10px",
|
||||||
|
"11px",
|
||||||
|
"12px",
|
||||||
|
"14px",
|
||||||
|
"16px",
|
||||||
|
"18px",
|
||||||
|
"20px",
|
||||||
|
"24px",
|
||||||
|
"28px",
|
||||||
|
"32px",
|
||||||
|
"36px",
|
||||||
|
"48px",
|
||||||
|
];
|
||||||
|
Quill.register(SizeStyle, true);
|
||||||
|
(Quill as any).__bohaRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Font = Quill.import("attributors/class/font") as any;
|
||||||
|
const SIZE_WHITELIST = [
|
||||||
|
"8px",
|
||||||
|
"9px",
|
||||||
|
"10px",
|
||||||
|
"11px",
|
||||||
|
"12px",
|
||||||
|
"14px",
|
||||||
|
"16px",
|
||||||
|
"18px",
|
||||||
|
"20px",
|
||||||
|
"24px",
|
||||||
|
"28px",
|
||||||
|
"32px",
|
||||||
|
"36px",
|
||||||
|
"48px",
|
||||||
|
];
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#000000",
|
"#000000",
|
||||||
"#1a1a1a",
|
"#1a1a1a",
|
||||||
@@ -36,6 +95,8 @@ const COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TOOLBAR = [
|
const TOOLBAR = [
|
||||||
|
[{ font: Font.whitelist }],
|
||||||
|
[{ size: SIZE_WHITELIST }],
|
||||||
["bold", "italic", "underline", "strike"],
|
["bold", "italic", "underline", "strike"],
|
||||||
[{ color: COLORS }, { background: COLORS }],
|
[{ color: COLORS }, { background: COLORS }],
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
@@ -46,6 +107,8 @@ const TOOLBAR = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const FORMATS = [
|
const FORMATS = [
|
||||||
|
"font",
|
||||||
|
"size",
|
||||||
"bold",
|
"bold",
|
||||||
"italic",
|
"italic",
|
||||||
"underline",
|
"underline",
|
||||||
@@ -96,16 +159,9 @@ export default function RichEditor({
|
|||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!quillRef.current) return;
|
|
||||||
const editor = quillRef.current.getEditor();
|
|
||||||
editor.root.style.fontFamily = "Tahoma, sans-serif";
|
|
||||||
editor.root.style.fontSize = "14px";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="admin-rich-editor"
|
className="rich-editor"
|
||||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
|
|||||||
|
|
||||||
export interface ShiftFormModalProps {
|
export interface ShiftFormModalProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
isOpen: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
form: ShiftFormData;
|
form: ShiftFormData;
|
||||||
@@ -85,15 +85,7 @@ export interface ShiftFormModalProps {
|
|||||||
|
|
||||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||||
const totalWork = calcFormWorkMinutes(form);
|
const totalWork = calcFormWorkMinutes(form);
|
||||||
const totalProject = calcProjectMinutesTotal(
|
const totalProject = calcProjectMinutesTotal(projectLogs);
|
||||||
projectLogs.map((l) => ({
|
|
||||||
...l,
|
|
||||||
project_id:
|
|
||||||
l.project_id !== "" && l.project_id != null
|
|
||||||
? Number(l.project_id)
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const remaining = totalWork - totalProject;
|
const remaining = totalWork - totalProject;
|
||||||
const hasLogs = projectLogs.some((l) => l.project_id);
|
const hasLogs = projectLogs.some((l) => l.project_id);
|
||||||
|
|
||||||
@@ -204,7 +196,7 @@ function ProjectLogRow({
|
|||||||
|
|
||||||
export default function ShiftFormModal({
|
export default function ShiftFormModal({
|
||||||
mode,
|
mode,
|
||||||
isOpen,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
form,
|
form,
|
||||||
@@ -216,7 +208,7 @@ export default function ShiftFormModal({
|
|||||||
onShiftDateChange,
|
onShiftDateChange,
|
||||||
editingRecord,
|
editingRecord,
|
||||||
}: ShiftFormModalProps) {
|
}: ShiftFormModalProps) {
|
||||||
useModalLock(isOpen);
|
useModalLock(show);
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
@@ -248,7 +240,7 @@ export default function ShiftFormModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -259,16 +251,13 @@ export default function ShiftFormModal({
|
|||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal admin-modal-lg"
|
className="admin-modal admin-modal-lg"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="shift-form-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
<div className="admin-modal-header">
|
||||||
<h2 id="shift-form-modal-title" className="admin-modal-title">
|
<h2 className="admin-modal-title">
|
||||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||||
</h2>
|
</h2>
|
||||||
{!isCreate && editingRecord && (
|
{!isCreate && editingRecord && (
|
||||||
@@ -337,6 +326,7 @@ export default function ShiftFormModal({
|
|||||||
<option value="work">Práce</option>
|
<option value="work">Práce</option>
|
||||||
<option value="vacation">Dovolená</option>
|
<option value="vacation">Dovolená</option>
|
||||||
<option value="sick">Nemoc</option>
|
<option value="sick">Nemoc</option>
|
||||||
|
<option value="holiday">Svátek</option>
|
||||||
<option value="unpaid">Neplacené volno</option>
|
<option value="unpaid">Neplacené volno</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/attendance/admin",
|
path: "/attendance/admin",
|
||||||
label: "Správa",
|
label: "Správa",
|
||||||
permission: "attendance.manage",
|
permission: "attendance.admin",
|
||||||
matchPrefix: "/attendance/admin",
|
matchPrefix: "/attendance/admin",
|
||||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||||
icon: (
|
icon: (
|
||||||
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/trips/admin",
|
path: "/trips/admin",
|
||||||
label: "Správa",
|
label: "Správa",
|
||||||
permission: "trips.manage",
|
permission: "trips.admin",
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/vehicles",
|
path: "/vehicles",
|
||||||
label: "Vozidla",
|
label: "Vozidla",
|
||||||
permission: "vehicles.manage",
|
permission: "trips.vehicles",
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -315,202 +315,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/offers/customers",
|
path: "/offers/customers",
|
||||||
label: "Zákazníci",
|
label: "Zákazníci",
|
||||||
permission: "customers.view",
|
permission: "offers.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: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -551,13 +356,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
label: "Nastavení",
|
label: "Nastavení",
|
||||||
permission: [
|
permission: "settings.manage",
|
||||||
"settings.company",
|
|
||||||
"settings.banking",
|
|
||||||
"settings.roles",
|
|
||||||
"settings.system",
|
|
||||||
"settings.templates",
|
|
||||||
],
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KPI_CLASS_MAP: Record<number, string> = {
|
const KPI_CLASS_MAP: Record<number, string> = {
|
||||||
4: "admin-kpi-4",
|
4: "dash-kpi-4",
|
||||||
3: "admin-kpi-3",
|
3: "dash-kpi-3",
|
||||||
2: "admin-kpi-2",
|
2: "dash-kpi-2",
|
||||||
1: "admin-kpi-1",
|
1: "dash-kpi-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||||
@@ -121,11 +121,11 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "dash-kpi-4";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`admin-kpi-grid ${kpiClass}`}
|
className={`dash-kpi-grid ${kpiClass}`}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
|
|||||||
@@ -89,21 +89,6 @@ export default function DashProfile({
|
|||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const dataToSave = { ...formData };
|
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 {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -344,24 +329,9 @@ export default function DashProfile({
|
|||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">
|
<label className="admin-form-label">
|
||||||
Nové heslo
|
Nové heslo (ponechte prázdné pro zachování stávajícího)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -373,9 +343,27 @@ export default function DashProfile({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import ConfirmModal from "../ConfirmModal";
|
import ConfirmModal from "../ConfirmModal";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
import useModalLock from "../../hooks/useModalLock";
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
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 {
|
interface DeleteModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
@@ -65,10 +77,9 @@ function getDeviceIcon(iconType?: string) {
|
|||||||
|
|
||||||
export default function DashSessions() {
|
export default function DashSessions() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: sessions = [], isPending: sessionsLoading } =
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
useQuery(sessionsOptions());
|
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||||
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
|
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
session: null,
|
session: null,
|
||||||
@@ -78,6 +89,26 @@ export default function DashSessions() {
|
|||||||
|
|
||||||
useModalLock(deleteAllModal);
|
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 () => {
|
const handleDeleteSession = async () => {
|
||||||
if (!deleteModal.session) {
|
if (!deleteModal.session) {
|
||||||
return;
|
return;
|
||||||
@@ -91,7 +122,7 @@ export default function DashSessions() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setDeleteModal({ isOpen: false, session: null });
|
setDeleteModal({ isOpen: false, session: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||||
alert.success("Relace byla ukončena");
|
alert.success("Relace byla ukončena");
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se ukončit relaci");
|
alert.error(data.error || "Nepodařilo se ukončit relaci");
|
||||||
@@ -112,7 +143,7 @@ export default function DashSessions() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setDeleteAllModal(false);
|
setDeleteAllModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
setSessions((prev) => prev.filter((s) => s.is_current));
|
||||||
alert.success(data.message || "Ostatní relace byly ukončeny");
|
alert.success(data.message || "Ostatní relace byly ukončeny");
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se ukončit relace");
|
alert.error(data.error || "Nepodařilo se ukončit relace");
|
||||||
@@ -152,83 +183,97 @@ export default function DashSessions() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
{sessionsLoading ? (
|
{sessionsLoading && (
|
||||||
<div className="admin-loading">
|
<div
|
||||||
<div className="admin-spinner" />
|
className="admin-skeleton"
|
||||||
</div>
|
style={{ padding: "1rem", gap: "1rem" }}
|
||||||
) : (
|
>
|
||||||
<>
|
{[0, 1, 2].map((i) => (
|
||||||
{sessions.length === 0 && (
|
<div key={i} className="admin-skeleton-row">
|
||||||
<div
|
<div className="admin-skeleton-line circle" />
|
||||||
className="text-secondary"
|
<div className="flex-1">
|
||||||
style={{
|
|
||||||
padding: "1.5rem",
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Žádné aktivní relace
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sessions.length > 0 && (
|
|
||||||
<div className="dash-sessions-list">
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
className="admin-skeleton-line w-1/2"
|
||||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
style={{ marginBottom: "0.5rem" }}
|
||||||
>
|
/>
|
||||||
<div className="dash-session-icon">
|
<div
|
||||||
{getDeviceIcon(session.device_info?.icon)}
|
className="admin-skeleton-line w-1/3"
|
||||||
</div>
|
style={{ height: "10px" }}
|
||||||
<div className="dash-session-info">
|
/>
|
||||||
<div className="dash-session-device">
|
</div>
|
||||||
{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>
|
||||||
)}
|
))}
|
||||||
</>
|
</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="sessions-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`session-item ${session.is_current ? "session-item-current" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="session-icon">
|
||||||
|
{getDeviceIcon(session.device_info?.icon)}
|
||||||
|
</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="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="session-meta">
|
||||||
|
<span>{session.ip_address}</span>
|
||||||
|
<span className="session-meta-separator">|</span>
|
||||||
|
<span>{formatSessionDate(session.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
@@ -40,15 +39,6 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const counterRef = useRef(0);
|
const counterRef = useRef(0);
|
||||||
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
timeoutsRef.current.forEach(clearTimeout);
|
|
||||||
timeoutsRef.current.clear();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addAlert = useCallback(
|
const addAlert = useCallback(
|
||||||
(message: string, type = "success", duration = 4000) => {
|
(message: string, type = "success", duration = 4000) => {
|
||||||
const id = `${Date.now()}-${counterRef.current++}`;
|
const id = `${Date.now()}-${counterRef.current++}`;
|
||||||
@@ -57,11 +47,7 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
{ id, message, type: type as Alert["type"] },
|
{ id, message, type: type as Alert["type"] },
|
||||||
]);
|
]);
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
setTimeout(() => removeAlert(id), duration);
|
||||||
timeoutsRef.current.delete(timeoutId);
|
|
||||||
removeAlert(id);
|
|
||||||
}, duration);
|
|
||||||
timeoutsRef.current.add(timeoutId);
|
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,32 +84,32 @@ function mapUser(u: Record<string, unknown> | null): User | null {
|
|||||||
} as User;
|
} as User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let accessToken: string | null = null;
|
||||||
|
let tokenExpiresAt: number | null = null;
|
||||||
|
let cachedUser: User | null = null;
|
||||||
|
let sessionFetched = false;
|
||||||
|
let silentRefreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const accessTokenRef = useRef<string | null>(null);
|
const [user, setUser] = useState<User | null>(cachedUser);
|
||||||
const tokenExpiresAtRef = useRef<number | null>(null);
|
const [loading, setLoading] = useState(!sessionFetched);
|
||||||
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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cachedUser = user;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const getAccessTokenFn = useCallback((): string | null => {
|
const getAccessTokenFn = useCallback((): string | null => {
|
||||||
if (
|
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
|
||||||
!tokenExpiresAtRef.current ||
|
return accessToken;
|
||||||
Date.now() > tokenExpiresAtRef.current - 30000
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
return accessTokenRef.current;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setAccessTokenFn = useCallback(
|
const setAccessTokenFn = useCallback(
|
||||||
(token: string | null, expiresIn?: number) => {
|
(token: string | null, expiresIn?: number) => {
|
||||||
const ttl = expiresIn ?? 900; // default 15 min matching backend config
|
const ttl = expiresIn ?? 900; // default 15 min matching backend config
|
||||||
accessTokenRef.current = token;
|
accessToken = token;
|
||||||
tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
|
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
|
||||||
if (refreshTimeoutRef.current) {
|
if (refreshTimeoutRef.current) {
|
||||||
clearTimeout(refreshTimeoutRef.current);
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
@@ -126,8 +126,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||||
if (silentRefreshInFlightRef.current)
|
if (silentRefreshInFlight) return silentRefreshInFlight;
|
||||||
return silentRefreshInFlightRef.current;
|
|
||||||
|
|
||||||
const promise = (async (): Promise<boolean> => {
|
const promise = (async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -139,24 +138,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (data.success && data.data?.access_token) {
|
if (data.success && data.data?.access_token) {
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
hadValidSessionRef.current = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
accessTokenRef.current = null;
|
accessToken = null;
|
||||||
tokenExpiresAtRef.current = null;
|
tokenExpiresAt = null;
|
||||||
setUser(null);
|
setUser(null);
|
||||||
cachedUserRef.current = null;
|
cachedUser = null;
|
||||||
if (hadValidSessionRef.current) setSessionExpired();
|
setSessionExpired();
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — don't kick the user out, just return false
|
// Network error — don't kick the user out, just return false
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
silentRefreshInFlightRef.current = null;
|
silentRefreshInFlight = null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
silentRefreshInFlightRef.current = promise;
|
silentRefreshInFlight = promise;
|
||||||
return promise;
|
return promise;
|
||||||
}, [setAccessTokenFn]);
|
}, [setAccessTokenFn]);
|
||||||
|
|
||||||
@@ -174,13 +172,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.status === 429 || response.status >= 500)
|
if (response.status === 429 || response.status >= 500)
|
||||||
return !!cachedUserRef.current;
|
return !!cachedUser;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.data?.user) {
|
if (data.success && data.data?.user) {
|
||||||
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
|
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUser = mapUser(data.data.user);
|
||||||
hadValidSessionRef.current = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,15 +185,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const refreshed = await silentRefresh();
|
const refreshed = await silentRefresh();
|
||||||
if (refreshed) return true;
|
if (refreshed) return true;
|
||||||
setUser(null);
|
setUser(null);
|
||||||
cachedUserRef.current = null;
|
cachedUser = null;
|
||||||
accessTokenRef.current = null;
|
accessToken = null;
|
||||||
tokenExpiresAtRef.current = null;
|
tokenExpiresAt = null;
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
return !!cachedUserRef.current;
|
return !!cachedUser;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
sessionFetchedRef.current = true;
|
sessionFetched = true;
|
||||||
}
|
}
|
||||||
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
|
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
|
||||||
|
|
||||||
@@ -234,9 +231,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUser = mapUser(data.data.user);
|
||||||
sessionFetchedRef.current = true;
|
sessionFetched = true;
|
||||||
hadValidSessionRef.current = true;
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
@@ -268,16 +264,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
login_token: loginToken,
|
login_token: loginToken,
|
||||||
totp_code: code,
|
totp_code: code,
|
||||||
remember_me: remember,
|
remember_me: remember,
|
||||||
isBackup,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUser = mapUser(data.data.user);
|
||||||
sessionFetchedRef.current = true;
|
sessionFetched = true;
|
||||||
hadValidSessionRef.current = true;
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
@@ -302,12 +296,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
} finally {
|
} finally {
|
||||||
accessTokenRef.current = null;
|
accessToken = null;
|
||||||
tokenExpiresAtRef.current = null;
|
tokenExpiresAt = null;
|
||||||
setUser(null);
|
setUser(null);
|
||||||
cachedUserRef.current = null;
|
cachedUser = null;
|
||||||
sessionFetchedRef.current = false;
|
sessionFetched = false;
|
||||||
hadValidSessionRef.current = false;
|
|
||||||
if (refreshTimeoutRef.current) {
|
if (refreshTimeoutRef.current) {
|
||||||
clearTimeout(refreshTimeoutRef.current);
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
|
|||||||
@@ -1,3 +1,103 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Stat Cards
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card.success::before {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
.admin-stat-card.warning::before {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
.admin-stat-card.danger::before {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
.admin-stat-card.info::before {
|
||||||
|
background: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-footer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-icon.danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.admin-stat-icon.info {
|
||||||
|
background: var(--info-soft);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
.admin-stat-icon.success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.admin-stat-icon.warning {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Dashboard
|
Dashboard
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -13,6 +113,26 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KPI grid */
|
||||||
|
.dash-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-kpi-4 {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
.dash-kpi-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.dash-kpi-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.dash-kpi-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Quick actions */
|
/* Quick actions */
|
||||||
.dash-quick-actions {
|
.dash-quick-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -392,9 +512,16 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-kpi-4 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.dash-kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
.dash-quick-actions {
|
.dash-quick-actions {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
@@ -416,6 +543,9 @@
|
|||||||
.dash-quick-actions {
|
.dash-quick-actions {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.dash-kpi-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.dash-profile-grid {
|
.dash-profile-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -425,12 +555,12 @@
|
|||||||
Sessions / Devices
|
Sessions / Devices
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.dash-sessions-list {
|
.sessions-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item {
|
.session-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -439,23 +569,23 @@
|
|||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item:last-child {
|
.session-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item:hover {
|
.session-item:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item-current {
|
.session-item-current {
|
||||||
background: var(--row-current);
|
background: var(--row-current);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item-current:hover {
|
.session-item-current:hover {
|
||||||
background: var(--row-current-hover);
|
background: var(--row-current-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-icon {
|
.session-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
@@ -467,17 +597,17 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-item-current .dash-session-icon {
|
.session-item-current .session-icon {
|
||||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-info {
|
.session-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-device {
|
.session-device {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -486,7 +616,7 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-meta {
|
.session-meta {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
@@ -496,30 +626,30 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-meta-separator {
|
.session-meta-separator {
|
||||||
color: var(--border-color);
|
color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-actions {
|
.session-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.dash-session-item {
|
.session-item {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-icon {
|
.session-icon {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-device {
|
.session-device {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-session-meta {
|
.session-meta {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
React DatePicker Overrides
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.react-datepicker-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 100 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent flash at top-left before popper calculates position */
|
|
||||||
#datepicker-portal .react-datepicker-popper {
|
|
||||||
opacity: 0;
|
|
||||||
animation: dp-fade-in 0.01s forwards 0.02s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dp-fade-in {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
font-family: inherit !important;
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
border: 1px solid var(--border-color) !important;
|
|
||||||
border-radius: var(--border-radius-sm) !important;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__triangle {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.react-datepicker__header {
|
|
||||||
background-color: var(--bg-tertiary) !important;
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
padding-top: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__current-month,
|
|
||||||
.react-datepicker-time__header {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-name {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Days */
|
|
||||||
.react-datepicker__day {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition:
|
|
||||||
background 0.15s,
|
|
||||||
color 0.15s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected,
|
|
||||||
.react-datepicker__day--keyboard-selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--today {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--outside-month {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
opacity: 0.3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation arrows */
|
|
||||||
.react-datepicker__navigation {
|
|
||||||
top: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation-icon::before {
|
|
||||||
border-color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation:hover *::before {
|
|
||||||
border-color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Year dropdown */
|
|
||||||
.react-datepicker__year-dropdown,
|
|
||||||
.react-datepicker__month-dropdown,
|
|
||||||
.react-datepicker__year-read-view,
|
|
||||||
.react-datepicker__month-read-view {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time picker */
|
|
||||||
.react-datepicker__time-container {
|
|
||||||
border-left: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container .react-datepicker__time {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
transition: background 0.15s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item--selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Month picker */
|
|
||||||
.react-datepicker__monthPicker {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-year-header {
|
|
||||||
background-color: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-wrapper {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
padding: 0.5rem !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: background 0.15s !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text--keyboard-selected,
|
|
||||||
.react-datepicker__month-text--selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text--today {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.react-datepicker__input-container input {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__close-icon::after {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
File Manager
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.fm-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-full-path {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
user-select: all;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-segment {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-sep {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin: 0 4px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn.active {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-new-folder {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-new-folder .admin-form-input {
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-content {
|
|
||||||
position: relative;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-content.fm-drag-over {
|
|
||||||
border: 2px dashed var(--accent-color);
|
|
||||||
background: var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-dropzone-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
z-index: 5;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 2.5rem 1rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-folder-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-folder-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-item-count {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-file-name {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-name-cell {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-symlink-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Forms
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"],
|
|
||||||
.admin-form-input[type="time"],
|
|
||||||
.admin-form-input[type="month"],
|
|
||||||
.admin-form-input[type="number"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
text-align: left;
|
|
||||||
height: 36px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="number"]::-webkit-inner-spin-button,
|
|
||||||
.admin-form-input[type="number"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-date-and-time-value,
|
|
||||||
.admin-form-input[type="time"]::-webkit-date-and-time-value,
|
|
||||||
.admin-form-input[type="month"]::-webkit-date-and-time-value {
|
|
||||||
text-align: left;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-datetime-edit,
|
|
||||||
.admin-form-input[type="time"]::-webkit-datetime-edit,
|
|
||||||
.admin-form-input[type="month"]::-webkit-datetime-edit {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
|
|
||||||
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
|
|
||||||
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
|
|
||||||
filter: var(--calendar-icon-filter, none);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select */
|
|
||||||
.admin-form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
min-height: 36px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: var(--select-arrow);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.75rem center;
|
|
||||||
padding-right: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-select:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-select option {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Textarea */
|
|
||||||
.admin-form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
resize: vertical;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-textarea:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox */
|
|
||||||
.admin-form-checkbox {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input + span::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-right: 8px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
vertical-align: middle;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition),
|
|
||||||
box-shadow var(--transition),
|
|
||||||
background var(--transition);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:checked + span::before {
|
|
||||||
background: var(--accent-color);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
|
||||||
background-size: 12px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:focus + span::before {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox:hover
|
|
||||||
input:not(:checked):not(:disabled):not(:indeterminate)
|
|
||||||
+ span::before {
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:indeterminate + span::before {
|
|
||||||
background: var(--accent-color);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
|
|
||||||
background-size: 12px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:disabled + span::before {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox:has(input:disabled) {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reorderable List */
|
|
||||||
.admin-reorder-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-label.accent {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon:disabled {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Rows (Grid Layouts) */
|
|
||||||
.admin-form-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-form-row-4 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.admin-form-row,
|
|
||||||
.admin-form-row-3 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-form-row-4,
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Utilities */
|
|
||||||
.admin-form-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Required field indicator */
|
|
||||||
.admin-form-label.required::after {
|
|
||||||
content: " *";
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline field errors */
|
|
||||||
.admin-form-group.has-error .admin-form-input,
|
|
||||||
.admin-form-group.has-error .admin-form-select,
|
|
||||||
.admin-form-group.has-error .admin-form-textarea {
|
|
||||||
border-color: var(--danger);
|
|
||||||
box-shadow: 0 0 0 3px var(--danger-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-group.has-error .admin-form-label {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-error {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--danger);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch targets - min 44px on mobile */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-form-input,
|
|
||||||
.admin-form-select,
|
|
||||||
.admin-form-textarea {
|
|
||||||
min-height: 44px;
|
|
||||||
font-size: 16px; /* prevent auto-zoom on iOS */
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input + span::before {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Customer Selector
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-customer-select {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--input-bg);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected span {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected .admin-btn-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
max-height: 260px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item:hover {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item div:first-child {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item div:last-child {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
48
src/admin/hooks/useApiCall.ts
Normal file
48
src/admin/hooks/useApiCall.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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 response = await apiFetch(url, {
|
||||||
|
...options,
|
||||||
|
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,7 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { isHoliday } from "../../utils/czech-holidays";
|
|
||||||
import {
|
import {
|
||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
calcFormWorkMinutes,
|
calcFormWorkMinutes,
|
||||||
@@ -11,17 +9,9 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
getLeaveTypeName,
|
getLeaveTypeName,
|
||||||
|
getLeaveTypeBadgeClass,
|
||||||
formatTimeOrDatetimePrint,
|
formatTimeOrDatetimePrint,
|
||||||
calculateWorkMinutesPrint,
|
calculateWorkMinutesPrint,
|
||||||
isWeekendDate,
|
|
||||||
getDaysInMonth,
|
|
||||||
shiftDateForMonth,
|
|
||||||
formatHoursDecimal,
|
|
||||||
calculateNightMinutes,
|
|
||||||
normalizeDateStr,
|
|
||||||
holidaysInMonth,
|
|
||||||
calculateFreeHolidayHours,
|
|
||||||
getCzechWeekday,
|
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import type {
|
import type {
|
||||||
ShiftFormData,
|
ShiftFormData,
|
||||||
@@ -38,7 +28,7 @@ interface AlertContext {
|
|||||||
alert: { success: (msg: string) => void; error: (msg: string) => void };
|
alert: { success: (msg: string) => void; error: (msg: string) => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendanceRecord {
|
interface AttendanceRecord {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
shift_date: string;
|
shift_date: string;
|
||||||
@@ -48,7 +38,7 @@ export interface AttendanceRecord {
|
|||||||
departure_time?: string | null;
|
departure_time?: string | null;
|
||||||
break_start?: string | null;
|
break_start?: string | null;
|
||||||
break_end?: string | null;
|
break_end?: string | null;
|
||||||
notes?: string | null;
|
notes?: string;
|
||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
project_name?: string;
|
project_name?: string;
|
||||||
project_logs?: Array<{
|
project_logs?: Array<{
|
||||||
@@ -82,6 +72,7 @@ interface UserTotal {
|
|||||||
working: boolean;
|
working: boolean;
|
||||||
vacation_hours: number;
|
vacation_hours: number;
|
||||||
sick_hours: number;
|
sick_hours: number;
|
||||||
|
holiday_hours: number;
|
||||||
unpaid_hours: number;
|
unpaid_hours: number;
|
||||||
overtime: number;
|
overtime: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
@@ -89,16 +80,6 @@ interface UserTotal {
|
|||||||
business_days: number;
|
business_days: number;
|
||||||
worked_hours: number;
|
worked_hours: number;
|
||||||
covered: 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 {
|
interface LeaveBalance {
|
||||||
@@ -139,11 +120,6 @@ const combineDatetime = (date: string, time: string): string | null =>
|
|||||||
/**
|
/**
|
||||||
* Compute per-user totals from raw attendance records.
|
* Compute per-user totals from raw attendance records.
|
||||||
* This replaces the server-side `user_totals` that the PHP backend returned.
|
* 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(
|
function computeUserTotals(
|
||||||
records: AttendanceRecord[],
|
records: AttendanceRecord[],
|
||||||
@@ -151,7 +127,6 @@ function computeUserTotals(
|
|||||||
month: string,
|
month: string,
|
||||||
): Record<string, UserTotal> {
|
): Record<string, UserTotal> {
|
||||||
const totals: Record<string, UserTotal> = {};
|
const totals: Record<string, UserTotal> = {};
|
||||||
const recordsByUser = new Map<number, AttendanceRecord[]>();
|
|
||||||
|
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
const uid = String(rec.user_id);
|
const uid = String(rec.user_id);
|
||||||
@@ -168,6 +143,7 @@ function computeUserTotals(
|
|||||||
working: false,
|
working: false,
|
||||||
vacation_hours: 0,
|
vacation_hours: 0,
|
||||||
sick_hours: 0,
|
sick_hours: 0,
|
||||||
|
holiday_hours: 0,
|
||||||
unpaid_hours: 0,
|
unpaid_hours: 0,
|
||||||
overtime: 0,
|
overtime: 0,
|
||||||
missing: 0,
|
missing: 0,
|
||||||
@@ -175,23 +151,15 @@ function computeUserTotals(
|
|||||||
business_days: 0,
|
business_days: 0,
|
||||||
worked_hours: 0,
|
worked_hours: 0,
|
||||||
covered: 0,
|
covered: 0,
|
||||||
records: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = totals[uid];
|
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";
|
const leaveType = rec.leave_type || "work";
|
||||||
|
|
||||||
if (leaveType === "work") {
|
if (leaveType === "work") {
|
||||||
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
|
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
|
||||||
t.minutes += calculateWorkMinutes({
|
t.minutes += calculateWorkMinutes(rec);
|
||||||
...rec,
|
|
||||||
notes: rec.notes ?? undefined,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const leaveHours = Number(rec.leave_hours) || 8;
|
const leaveHours = Number(rec.leave_hours) || 8;
|
||||||
switch (leaveType) {
|
switch (leaveType) {
|
||||||
@@ -201,14 +169,12 @@ function computeUserTotals(
|
|||||||
case "sick":
|
case "sick":
|
||||||
t.sick_hours += leaveHours;
|
t.sick_hours += leaveHours;
|
||||||
break;
|
break;
|
||||||
|
case "holiday":
|
||||||
|
t.holiday_hours += leaveHours;
|
||||||
|
break;
|
||||||
case "unpaid":
|
case "unpaid":
|
||||||
t.unpaid_hours += leaveHours;
|
t.unpaid_hours += leaveHours;
|
||||||
break;
|
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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +189,7 @@ function computeUserTotals(
|
|||||||
const yr = parseInt(yearStr, 10);
|
const yr = parseInt(yearStr, 10);
|
||||||
const mo = parseInt(monthStr, 10) - 1;
|
const mo = parseInt(monthStr, 10) - 1;
|
||||||
|
|
||||||
// Count Mon-Fri business days in month (INCLUDING holidays).
|
// Count business days in month (Mon-Fri)
|
||||||
// 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;
|
let rawBizDays = 0;
|
||||||
const cur = new Date(yr, mo, 1);
|
const cur = new Date(yr, mo, 1);
|
||||||
while (cur.getMonth() === mo) {
|
while (cur.getMonth() === mo) {
|
||||||
@@ -235,100 +198,23 @@ function computeUserTotals(
|
|||||||
cur.setDate(cur.getDate() + 1);
|
cur.setDate(cur.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
// Subtract holiday days from business days for this user
|
||||||
|
const holidayDays = Math.round(t.holiday_hours / 8);
|
||||||
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
|
const bizDays = Math.max(0, rawBizDays - holidayDays);
|
||||||
const workedHoursRaw = t.minutes / 60;
|
const fund = bizDays * 8;
|
||||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
const workedHours = Math.round((t.minutes / 60) * 10) / 10;
|
||||||
|
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
|
||||||
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
|
const leaveHours = t.vacation_hours + t.sick_hours;
|
||||||
const weekendHours = userRecords
|
const covered = Math.round((workedHours + leaveHours) * 10) / 10;
|
||||||
.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.fund = fund;
|
||||||
t.business_days = rawBizDays;
|
t.business_days = bizDays;
|
||||||
t.worked_hours = workedHours;
|
t.worked_hours = workedHours;
|
||||||
t.covered = covered;
|
t.covered = covered;
|
||||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
||||||
t.overtime = Math.max(0, Math.round((covered - fund) * 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;
|
return totals;
|
||||||
@@ -338,13 +224,12 @@ function computeUserTotals(
|
|||||||
// Print helpers
|
// Print helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function renderFundStatus(userData: Record<string, any>): string {
|
||||||
return str
|
if (userData.overtime > 0)
|
||||||
.replace(/&/g, "&")
|
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`;
|
||||||
.replace(/</g, "<")
|
if (userData.missing > 0)
|
||||||
.replace(/>/g, ">")
|
return `<span style="color:#dc2626">−${userData.missing}h</span>`;
|
||||||
.replace(/"/g, """)
|
return '<span style="color:#16a34a">splněno</span>';
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProjectLogsHtml(record: Record<string, any>): string {
|
function buildProjectLogsHtml(record: Record<string, any>): string {
|
||||||
@@ -370,11 +255,29 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
|||||||
h = 0;
|
h = 0;
|
||||||
m = 0;
|
m = 0;
|
||||||
}
|
}
|
||||||
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
return escapeHtml(record.project_name || "—");
|
return 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á ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
|
||||||
|
if (userData.vacation_hours > 0)
|
||||||
|
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`;
|
||||||
|
if (userData.sick_hours > 0)
|
||||||
|
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`;
|
||||||
|
if (userData.holiday_hours > 0)
|
||||||
|
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`;
|
||||||
|
if (userData.overtime > 0)
|
||||||
|
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`;
|
||||||
|
return `<div class="leave-summary">${parts}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserSectionHtml(
|
function buildUserSectionHtml(
|
||||||
@@ -382,215 +285,71 @@ function buildUserSectionHtml(
|
|||||||
userData: Record<string, any>,
|
userData: Record<string, any>,
|
||||||
printData: Record<string, any>,
|
printData: Record<string, any>,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
const leaveHtml = printData.leave_balances[userId]
|
||||||
const recordsByDate = new Map<string, Record<string, any>>();
|
? buildLeaveSummaryHtml(userId, userData, printData)
|
||||||
for (const r of userData.records || []) {
|
: "";
|
||||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate one row per day of the month.
|
const recordRows = (userData.records || [])
|
||||||
const [yearStr, monthStr] = printData.month.split("-");
|
.map((record: any) => {
|
||||||
const yr = parseInt(yearStr, 10);
|
const leaveType = record.leave_type || "work";
|
||||||
const mo = parseInt(monthStr, 10);
|
const isLeave = leaveType !== "work";
|
||||||
const daysInMonth = getDaysInMonth(yr, mo);
|
const workMinutes = calculateWorkMinutesPrint(record);
|
||||||
|
const hours = Math.floor(workMinutes / 60);
|
||||||
|
const mins = workMinutes % 60;
|
||||||
|
const breakCell =
|
||||||
|
isLeave || !record.break_start || !record.break_end
|
||||||
|
? "—"
|
||||||
|
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`;
|
||||||
|
|
||||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
return `<tr>
|
||||||
|
<td>${formatDate(record.shift_date)}</td>
|
||||||
const rows: string[] = [];
|
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
||||||
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">${breakCell}</td>
|
||||||
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
|
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
||||||
<td class="text-center">${hoursCell}</td>
|
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
|
||||||
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||||
<td>${escapeHtml(record.notes || "")}</td>
|
<td>${record.notes || ""}</td>
|
||||||
</tr>`);
|
</tr>`;
|
||||||
}
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
// ----- mzda-style summary numbers (computed here, not from userData,
|
const fundRow =
|
||||||
// because the backend's getPrintData only returns the legacy fields). -----
|
userData.fund !== null
|
||||||
|
? `<tr>
|
||||||
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
|
<td colspan="6" class="text-right">Fond měsíce:</td>
|
||||||
let workedMinutes = 0;
|
<td class="text-center">${userData.covered}h / ${userData.fund}h</td>
|
||||||
let weekendHoursRaw = 0;
|
<td colspan="2">${renderFundStatus(userData)}</td>
|
||||||
let nightMinutes = 0;
|
</tr>`
|
||||||
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">
|
return `<div class="user-section">
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
<h3>${escapeHtml(userData.name)}</h3>
|
<h3>${userData.name}</h3>
|
||||||
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
|
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
|
||||||
</div>
|
</div>
|
||||||
|
${leaveHtml}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:55px">Datum</th>
|
<th style="width:70px">Datum</th>
|
||||||
<th style="width:55px">Den</th>
|
<th style="width:70px">Typ</th>
|
||||||
<th style="width:60px">Typ</th>
|
<th class="text-center" style="width:70px">Příchod</th>
|
||||||
<th class="text-center" style="width:55px">Příchod</th>
|
<th class="text-center" style="width:90px">Pauza</th>
|
||||||
<th class="text-center" style="width:80px">Pauza</th>
|
<th class="text-center" style="width:70px">Odchod</th>
|
||||||
<th class="text-center" style="width:55px">Odchod</th>
|
<th class="text-center" style="width:80px">Hodiny</th>
|
||||||
<th class="text-center" style="width:85px">Hodiny</th>
|
|
||||||
<th>Projekty</th>
|
<th>Projekty</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>${rows.join("")}</tbody>
|
<tbody>${recordRows}</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-right">Odpracováno:</td>
|
||||||
|
<td class="text-center">${formatMinutes(userData.minutes)} h</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
${fundRow}
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
${summaryHtml}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,15 +365,9 @@ function buildPrintHtml(
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
|
<title>Docházka - ${pData.month_name}</title>
|
||||||
<style>
|
<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 {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
|
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
|
||||||
@@ -644,47 +397,19 @@ function buildPrintHtml(
|
|||||||
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
|
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
|
||||||
.user-section td { font-size: 10px; }
|
.user-section td { font-size: 10px; }
|
||||||
.user-section tr:nth-child(even) { background: #f9f9f9; }
|
.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-center { text-align: center; }
|
||||||
.text-right { text-align: right; }
|
.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; }
|
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
|
||||||
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
||||||
.badge-sick { background: #fee2e2; color: #dc2626; }
|
.badge-sick { background: #fee2e2; color: #dc2626; }
|
||||||
.badge-holiday { background: #dcfce7; color: #16a34a; }
|
.badge-holiday { background: #dcfce7; color: #16a34a; }
|
||||||
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
||||||
.badge-overtime { background: #fef3c7; color: #d97706; }
|
.badge-overtime { background: #fef3c7; color: #d97706; }
|
||||||
.user-summary {
|
.leave-summary {
|
||||||
margin-top: 10px; padding: 10px 15px;
|
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
|
||||||
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
|
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 { width: 100%; border-collapse: collapse; border: none; }
|
||||||
.print-wrapper-table > thead > tr > td,
|
.print-wrapper-table > thead > tr > td,
|
||||||
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
|
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
|
||||||
@@ -703,11 +428,11 @@ function buildPrintHtml(
|
|||||||
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
|
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
|
||||||
<div class="print-header-text">
|
<div class="print-header-text">
|
||||||
<h1>EVIDENCE DOCHÁZKY</h1>
|
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||||
<div class="company">${escapeHtml(companyName)}</div>
|
<div class="company">${companyName}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="print-header-right">
|
<div class="print-header-right">
|
||||||
<div class="period">${escapeHtml(pData.month_name)}</div>
|
<div class="period">${pData.month_name}</div>
|
||||||
${filterNote}
|
${filterNote}
|
||||||
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -727,7 +452,6 @@ function buildPrintHtml(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// ---- Core state ----
|
// ---- Core state ----
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
@@ -837,9 +561,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(`${API_BASE}/users?limit=1000`);
|
||||||
`${API_BASE}/attendance?action=attendance_users`,
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const apiUsers: ApiUser[] = result.data;
|
const apiUsers: ApiUser[] = result.data;
|
||||||
@@ -929,15 +651,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
formData: ShiftFormData,
|
formData: ShiftFormData,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const totalWork = calcFormWorkMinutes(formData);
|
const totalWork = calcFormWorkMinutes(formData);
|
||||||
const totalProject = calcProjectMinutesTotal(
|
const totalProject = calcProjectMinutesTotal(logs);
|
||||||
logs.map((l) => ({
|
|
||||||
...l,
|
|
||||||
project_id:
|
|
||||||
l.project_id !== "" && l.project_id != null
|
|
||||||
? Number(l.project_id)
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (totalWork > 0 && totalProject !== totalWork) {
|
if (totalWork > 0 && totalProject !== totalWork) {
|
||||||
const wH = Math.floor(totalWork / 60);
|
const wH = Math.floor(totalWork / 60);
|
||||||
const wM = totalWork % 60;
|
const wM = totalWork % 60;
|
||||||
@@ -1046,7 +760,6 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1118,7 +831,6 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowBulkModal(false);
|
setShowBulkModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1142,7 +854,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
const userName = record.users
|
const userName = record.users
|
||||||
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
|
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
|
||||||
record.users.username
|
record.users.username
|
||||||
: ((record as unknown as Record<string, unknown>).user_name as string) ||
|
: ((record as Record<string, unknown>).user_name as string) ||
|
||||||
`User #${record.user_id}`;
|
`User #${record.user_id}`;
|
||||||
const enriched = { ...record, user_name: userName };
|
const enriched = { ...record, user_name: userName };
|
||||||
setEditingRecord(enriched);
|
setEditingRecord(enriched);
|
||||||
@@ -1253,7 +965,6 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1283,7 +994,6 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, record: null });
|
setDeleteConfirm({ show: false, record: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
alert.success(
|
alert.success(
|
||||||
result.message || result.data?.message || "Záznam smazán",
|
result.message || result.data?.message || "Záznam smazán",
|
||||||
@@ -1325,7 +1035,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
|
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
|
||||||
: "";
|
: "";
|
||||||
const filterNote = pData.selected_user_name
|
const filterNote = pData.selected_user_name
|
||||||
? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
|
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
|
||||||
: "";
|
: "";
|
||||||
const bodyContent = buildPrintHtml(
|
const bodyContent = buildPrintHtml(
|
||||||
pData,
|
pData,
|
||||||
@@ -1339,9 +1049,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
printWindow.document.open();
|
printWindow.document.open();
|
||||||
printWindow.document.write(bodyContent);
|
printWindow.document.write(bodyContent);
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
printWindow.addEventListener("load", () => printWindow.print(), {
|
printWindow.onload = () => printWindow.print();
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
126
src/admin/hooks/useListData.ts
Normal file
126
src/admin/hooks/useListData.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
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) 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;
|
||||||
|
alert.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoad(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
endpoint,
|
||||||
|
debouncedSearch,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
dataKey,
|
||||||
|
JSON.stringify(extraParams),
|
||||||
|
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
return () => {
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
setItems,
|
||||||
|
loading,
|
||||||
|
initialLoad,
|
||||||
|
pagination,
|
||||||
|
refetch: fetchData,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
let activeLocks = 0;
|
|
||||||
|
|
||||||
export default function useModalLock(isOpen: boolean): void {
|
export default function useModalLock(isOpen: boolean): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (activeLocks === 0) document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
activeLocks++;
|
} else {
|
||||||
return () => {
|
document.body.style.overflow = "";
|
||||||
activeLocks = Math.max(0, activeLocks - 1);
|
|
||||||
if (activeLocks === 0) document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
|
||||||
interface SortState {
|
interface SortState {
|
||||||
sort: string;
|
sort: string;
|
||||||
@@ -13,10 +13,10 @@ export default function useTableSort(
|
|||||||
sort: defaultSort,
|
sort: defaultSort,
|
||||||
order: defaultOrder,
|
order: defaultOrder,
|
||||||
});
|
});
|
||||||
const [userClicked, setUserClicked] = useState(false);
|
const userClicked = useRef(false);
|
||||||
|
|
||||||
const handleSort = useCallback((column: string) => {
|
const handleSort = useCallback((column: string) => {
|
||||||
setUserClicked(true);
|
userClicked.current = true;
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.sort === column) {
|
if (prev.sort === column) {
|
||||||
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
|
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
|
||||||
@@ -25,7 +25,7 @@ export default function useTableSort(
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeSort = userClicked ? state.sort : null;
|
const activeSort = userClicked.current ? state.sort : null;
|
||||||
|
|
||||||
return { sort: state.sort, order: state.order, handleSort, activeSort };
|
return { sort: state.sort, order: state.order, handleSort, activeSort };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Layout
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-layout {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-layout {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Sidebar
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
/* -- Theme variables for sidebar -- */
|
|
||||||
[data-theme="dark"] .admin-sidebar {
|
|
||||||
--sb-bg: #141414;
|
|
||||||
--sb-border: #2a2a2a;
|
|
||||||
--sb-text: #a0a0a0;
|
|
||||||
--sb-text-hover: #ddd;
|
|
||||||
--sb-hover-bg: #1f1f1f;
|
|
||||||
--sb-active-bg: #ffffff;
|
|
||||||
--sb-active-text: #141414;
|
|
||||||
--sb-label: #444;
|
|
||||||
--sb-muted: #555;
|
|
||||||
--sb-scrollbar: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .admin-sidebar {
|
|
||||||
--sb-bg: #ffffff;
|
|
||||||
--sb-border: #e8e6e1;
|
|
||||||
--sb-text: #7c7c84;
|
|
||||||
--sb-text-hover: #1a1a1a;
|
|
||||||
--sb-hover-bg: #f5f4f2;
|
|
||||||
--sb-active-bg: #141414;
|
|
||||||
--sb-active-text: #ffffff;
|
|
||||||
--sb-label: #a0a0a0;
|
|
||||||
--sb-muted: #a0a0a0;
|
|
||||||
--sb-scrollbar: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh;
|
|
||||||
z-index: 50;
|
|
||||||
background: var(--sb-bg);
|
|
||||||
border-right: 1px solid var(--sb-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
padding-left: env(safe-area-inset-left, 0px);
|
|
||||||
padding-right: env(safe-area-inset-right, 0px);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
visibility: hidden;
|
|
||||||
transition:
|
|
||||||
transform 0.3s ease,
|
|
||||||
visibility 0.3s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
visibility: visible;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar {
|
|
||||||
right: auto;
|
|
||||||
width: 220px;
|
|
||||||
height: 100%;
|
|
||||||
transform: none;
|
|
||||||
visibility: visible;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .admin-sidebar {
|
|
||||||
box-shadow:
|
|
||||||
1px 0 0 0 var(--sb-border),
|
|
||||||
4px 0 16px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Overlay (mobile) */
|
|
||||||
.admin-sidebar-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 49;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-overlay.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar-overlay {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Header */
|
|
||||||
.admin-sidebar-header {
|
|
||||||
padding: 0 18px;
|
|
||||||
height: 73px;
|
|
||||||
border-bottom: 1px solid var(--sb-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-logo {
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-close {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-close:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar-close {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Navigation */
|
|
||||||
.admin-sidebar-nav {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--sb-scrollbar);
|
|
||||||
border-radius: 99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav Section */
|
|
||||||
.admin-nav-section {
|
|
||||||
padding: 14px 10px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--sb-label);
|
|
||||||
padding: 0 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav Item */
|
|
||||||
.admin-nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 450;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item.active {
|
|
||||||
background: var(--sb-active-bg);
|
|
||||||
color: var(--sb-active-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item.active svg {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.admin-nav-item {
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 15px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer */
|
|
||||||
.admin-sidebar-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
border-top: 1px solid var(--sb-border);
|
|
||||||
padding: 14px 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-details {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-name {
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-role {
|
|
||||||
color: var(--sb-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-sidebar-footer {
|
|
||||||
padding: 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-chip {
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn {
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Main Content Area
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-main {
|
|
||||||
margin-left: 220px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.admin-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 30;
|
|
||||||
height: calc(73px + env(safe-area-inset-top, 0px));
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 1rem;
|
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-header {
|
|
||||||
left: 220px;
|
|
||||||
padding: 0 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-menu-btn {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-menu-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header-theme-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: var(--transition);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header-theme-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-theme-icon {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(180deg) scale(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-theme-icon.visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.admin-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-content {
|
|
||||||
padding: 28px 32px;
|
|
||||||
padding-top: calc(73px + 28px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Page Headers
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-page-header {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-subtitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.admin-page-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-actions .admin-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Grid System
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid > .admin-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page header on mobile */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-page-title {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-subtitle {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
padding: 12px !important;
|
|
||||||
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid - single column on small mobile */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Settings Grid
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-settings-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-settings-grid > .admin-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.admin-settings-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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}` : ""}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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 || "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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"),
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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}` : ""}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
|
||||||
|
|
||||||
export const vehicleListOptions = () =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["vehicles"],
|
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
|
|
||||||
});
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
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}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -2,6 +2,63 @@
|
|||||||
Offers Module
|
Offers Module
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* Editor section cards */
|
||||||
|
.offers-editor-section {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings grid */
|
||||||
|
.offers-settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-settings-grid > .admin-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.offers-settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo section */
|
||||||
|
.offers-logo-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-logo-preview {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 100px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-logo-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* Items table */
|
/* Items table */
|
||||||
.offers-items-table {
|
.offers-items-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -46,6 +103,240 @@
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Totals summary */
|
||||||
|
.offers-totals-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-row span:last-child {
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-total {
|
||||||
|
border-top: 2px solid var(--text-primary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-total span:last-child {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scope sections list wrapper */
|
||||||
|
.offers-scope-list {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scope section card */
|
||||||
|
.offers-scope-section {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section:hover {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--border-color) 70%,
|
||||||
|
var(--accent-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-number {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section .admin-form {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Customer selector */
|
||||||
|
.offers-customer-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--input-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected .admin-btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item div:first-child {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item div:last-child {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template dropdown menu */
|
||||||
|
.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 */
|
/* Language badges */
|
||||||
.offers-lang-badge {
|
.offers-lang-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -70,20 +361,81 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact form row for 3+ columns */
|
||||||
|
.offers-form-row-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.offers-form-row-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs - zachovany pro zpetnou kompatibilitu, nove pouzivat admin-tabs/admin-tab */
|
||||||
|
.offers-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
background 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
|
0 0 0 1px var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* RichEditor (Quill) */
|
/* RichEditor (Quill) */
|
||||||
.admin-rich-editor {
|
.rich-editor {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .quill {
|
.rich-editor .quill {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.admin-rich-editor .ql-toolbar.ql-snow {
|
.rich-editor .ql-toolbar.ql-snow {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
@@ -93,60 +445,60 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-toolbar .ql-formats {
|
.rich-editor .ql-toolbar .ql-formats {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar buttons */
|
/* Toolbar buttons */
|
||||||
.admin-rich-editor .ql-snow .ql-stroke {
|
.rich-editor .ql-snow .ql-stroke {
|
||||||
stroke: var(--text-secondary);
|
stroke: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-fill {
|
.rich-editor .ql-snow .ql-fill {
|
||||||
fill: var(--text-secondary);
|
fill: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label {
|
.rich-editor .ql-snow .ql-picker-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow button:hover .ql-stroke,
|
.rich-editor .ql-snow button:hover .ql-stroke,
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
|
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
|
||||||
stroke: var(--text-primary);
|
stroke: var(--text-primary);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow button:hover .ql-fill,
|
.rich-editor .ql-snow button:hover .ql-fill,
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
|
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
|
||||||
fill: var(--text-primary);
|
fill: var(--text-primary);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow button:hover,
|
.rich-editor .ql-snow button:hover,
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover {
|
.rich-editor .ql-snow .ql-picker-label:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active state */
|
/* Active state */
|
||||||
.admin-rich-editor .ql-snow button.ql-active {
|
.rich-editor .ql-snow button.ql-active {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke {
|
.rich-editor .ql-snow button.ql-active .ql-stroke {
|
||||||
stroke: var(--accent-color);
|
stroke: var(--accent-color);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-fill,
|
.rich-editor .ql-snow button.ql-active .ql-fill,
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
|
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
|
||||||
fill: var(--accent-color);
|
fill: var(--accent-color);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
|
.rich-editor .ql-snow .ql-picker-item.ql-selected {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
|
.rich-editor .ql-snow .ql-picker-label.ql-active {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
|
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
|
||||||
stroke: var(--accent-color);
|
stroke: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdowns (font, size, color, align) */
|
/* Dropdowns (font, size, color, align) */
|
||||||
.admin-rich-editor .ql-snow .ql-picker-options {
|
.rich-editor .ql-snow .ql-picker-options {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
@@ -155,23 +507,23 @@
|
|||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item {
|
.rich-editor .ql-snow .ql-picker-item {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item:hover {
|
.rich-editor .ql-snow .ql-picker-item:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font picker */
|
/* Font picker */
|
||||||
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
|
.rich-editor .ql-snow .ql-font .ql-picker-options {
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
|
.rich-editor .ql-snow .ql-size .ql-picker-options {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -351,39 +703,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Editor area */
|
/* Editor area */
|
||||||
.admin-rich-editor .ql-container.ql-snow {
|
.rich-editor .ql-container.ql-snow {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0 0 0.5rem 0.5rem;
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
font-family: Tahoma, sans-serif;
|
font-size: 0.875rem;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-editor {
|
.rich-editor .ql-editor {
|
||||||
min-height: var(--re-min-height, 120px);
|
min-height: var(--re-min-height, 120px);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family: Tahoma, sans-serif;
|
font-size: 0.875rem;
|
||||||
font-size: 14px;
|
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-editor.ql-blank::before {
|
.rich-editor .ql-editor.ql-blank::before {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lists inside editor */
|
/* Lists inside editor */
|
||||||
.admin-rich-editor .ql-editor ul,
|
.rich-editor .ql-editor ul,
|
||||||
.admin-rich-editor .ql-editor ol {
|
.rich-editor .ql-editor ol {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Color picker */
|
/* Color picker */
|
||||||
.admin-rich-editor
|
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] {
|
||||||
.ql-snow
|
|
||||||
.ql-color-picker
|
|
||||||
.ql-picker-options[aria-hidden="false"] {
|
|
||||||
width: 176px;
|
width: 176px;
|
||||||
padding: 0.375rem;
|
padding: 0.375rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -391,7 +738,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
|
.rich-editor .ql-snow .ql-color-picker .ql-picker-item {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -401,7 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip (link editor) */
|
/* Tooltip (link editor) */
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip {
|
.rich-editor .ql-snow .ql-tooltip {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
@@ -409,7 +756,7 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
|
.rich-editor .ql-snow .ql-tooltip input[type="text"] {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -417,12 +764,12 @@
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip a {
|
.rich-editor .ql-snow .ql-tooltip a {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read-only rendered rich text (Quill HTML output) */
|
/* Read-only rendered rich text (Quill HTML output) */
|
||||||
.admin-rich-text-view {
|
.rich-text-view {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -431,102 +778,55 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view ul,
|
.rich-text-view ul,
|
||||||
.admin-rich-text-view ol {
|
.rich-text-view ol {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
margin: 0.25rem 0 0.75rem;
|
margin: 0.25rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view li {
|
.rich-text-view li {
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view a {
|
.rich-text-view a {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view strong,
|
.rich-text-view strong,
|
||||||
.admin-rich-text-view b {
|
.rich-text-view b {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view br + b,
|
.rich-text-view br + b,
|
||||||
.admin-rich-text-view br + strong {
|
.rich-text-view br + strong {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rich-text-view > br:first-child,
|
.rich-text-view > br:first-child,
|
||||||
.admin-rich-text-view ul + br,
|
.rich-text-view ul + br,
|
||||||
.admin-rich-text-view ol + br {
|
.rich-text-view ol + br {
|
||||||
display: none;
|
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) {
|
@media (max-width: 640px) {
|
||||||
|
.offers-editor-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.offers-items-table {
|
.offers-items-table {
|
||||||
margin: 0 -1rem;
|
margin: 0 -1rem;
|
||||||
width: calc(100% + 2rem);
|
width: calc(100% + 2rem);
|
||||||
border-radius: 0;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.offers-items-table .admin-table {
|
.offers-totals-summary {
|
||||||
min-width: 700px;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.offers-items-table .admin-table td {
|
.offers-totals-row {
|
||||||
padding: 6px;
|
min-width: unset;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,14 +874,6 @@
|
|||||||
color: var(--text-muted) !important;
|
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) */
|
/* Read-only form (invalidated offer detail) */
|
||||||
.offers-readonly input[readonly],
|
.offers-readonly input[readonly],
|
||||||
.offers-readonly select:disabled {
|
.offers-readonly select:disabled {
|
||||||
@@ -590,32 +882,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Offer draft indicator */
|
/* Offer draft indicator */
|
||||||
/* Filters */
|
.offers-draft-indicator {
|
||||||
.admin-filters {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.3rem;
|
||||||
align-items: flex-start;
|
font-size: 0.72rem;
|
||||||
}
|
font-weight: 500;
|
||||||
|
|
||||||
.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);
|
color: var(--text-tertiary);
|
||||||
text-transform: uppercase;
|
margin-top: 0.2rem;
|
||||||
letter-spacing: 0.03em;
|
opacity: 0.8;
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-group .admin-tabs {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-group .admin-form-select {
|
|
||||||
min-width: 10rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -15,9 +14,6 @@ import {
|
|||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import apiFetch from "../utils/api";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -65,6 +61,7 @@ interface MonthlyFund {
|
|||||||
leave_hours: number;
|
leave_hours: number;
|
||||||
vacation_hours: number;
|
vacation_hours: number;
|
||||||
sick_hours: number;
|
sick_hours: number;
|
||||||
|
holiday_hours: number;
|
||||||
unpaid_hours: number;
|
unpaid_hours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +75,12 @@ interface AttendanceData {
|
|||||||
active_project_id: number | null;
|
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) {
|
function getFundBarBackground(fund: MonthlyFund) {
|
||||||
if (fund.overtime > 0)
|
if (fund.overtime > 0)
|
||||||
return "linear-gradient(135deg, var(--warning), #d97706)";
|
return "linear-gradient(135deg, var(--warning), #d97706)";
|
||||||
@@ -89,55 +92,23 @@ function getFundBarBackground(fund: MonthlyFund) {
|
|||||||
export default function Attendance() {
|
export default function Attendance() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = 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 [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||||
const [leaveForm, setLeaveForm] = useState({
|
const [leaveForm, setLeaveForm] = useState({
|
||||||
leave_type: "vacation",
|
leave_type: "vacation",
|
||||||
date_from: new Date().toISOString().split("T")[0],
|
date_from: new Date().toISOString().split("T")[0],
|
||||||
@@ -146,51 +117,70 @@ export default function Attendance() {
|
|||||||
});
|
});
|
||||||
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [switchingProject, setSwitchingProject] = useState(false);
|
const [switchingProject, setSwitchingProject] = useState(false);
|
||||||
|
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
|
||||||
|
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
|
||||||
const [gpsConfirm, setGpsConfirm] = useState<{
|
const [gpsConfirm, setGpsConfirm] = useState<{
|
||||||
isOpen: boolean;
|
show: boolean;
|
||||||
action: string | null;
|
action: string | null;
|
||||||
}>({ isOpen: false, action: null });
|
}>({ show: false, action: null });
|
||||||
const geoAbortRef = useRef<AbortController | null>(null);
|
const geoAbortRef = useRef<AbortController | null>(null);
|
||||||
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const latestActionRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
|
||||||
if (geoAbortRef.current) geoAbortRef.current.abort();
|
if (geoAbortRef.current) geoAbortRef.current.abort();
|
||||||
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync notes from query data when the shift changes
|
const fetchData = useCallback(async () => {
|
||||||
useEffect(() => {
|
try {
|
||||||
if (statusQuery.data) {
|
const response = await apiFetch(`${API_BASE}/attendance/status`);
|
||||||
setNotes(statusQuery.data.ongoing_shift?.notes || "");
|
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);
|
||||||
}
|
}
|
||||||
}, [statusQuery.data]);
|
}, [alert]);
|
||||||
|
|
||||||
useModalLock(isLeaveModalOpen);
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalLock(showLeaveModal);
|
||||||
|
|
||||||
if (!hasPermission("attendance.record")) return <Forbidden />;
|
if (!hasPermission("attendance.record")) return <Forbidden />;
|
||||||
|
|
||||||
const handlePunch = (action: string) => {
|
const handlePunch = (action: string) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
latestActionRef.current = action;
|
|
||||||
|
|
||||||
// Some browsers silently hang on getCurrentPosition (especially with
|
|
||||||
// enableHighAccuracy:true on desktops without GPS). Use a short safety
|
|
||||||
// timeout and proceed without GPS rather than leaving the user stuck.
|
|
||||||
const safetyTimeout = setTimeout(() => {
|
|
||||||
if (mountedRef.current) {
|
|
||||||
submitPunch(action, {});
|
|
||||||
}
|
|
||||||
}, 6000);
|
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
clearTimeout(safetyTimeout);
|
|
||||||
alert.warning("GPS není dostupná");
|
alert.warning("GPS není dostupná");
|
||||||
submitPunch(action, {});
|
submitPunch(action, {});
|
||||||
return;
|
return;
|
||||||
@@ -198,21 +188,14 @@ export default function Attendance() {
|
|||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
clearTimeout(safetyTimeout);
|
const { latitude, longitude, accuracy } = position.coords;
|
||||||
if (!mountedRef.current) return;
|
submitPunch(action, { latitude, longitude, accuracy, address: "" });
|
||||||
try {
|
|
||||||
const { latitude, longitude, accuracy } = position.coords;
|
|
||||||
submitPunch(action, { latitude, longitude, accuracy, address: "" });
|
|
||||||
} catch {
|
|
||||||
submitPunch(action, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire-and-forget reverse geocoding to update the address later
|
|
||||||
if (geoAbortRef.current) geoAbortRef.current.abort();
|
if (geoAbortRef.current) geoAbortRef.current.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
geoAbortRef.current = controller;
|
geoAbortRef.current = controller;
|
||||||
fetch(
|
fetch(
|
||||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||||
{
|
{
|
||||||
headers: { "Accept-Language": "cs" },
|
headers: { "Accept-Language": "cs" },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -220,15 +203,13 @@ export default function Attendance() {
|
|||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((geoData) => {
|
.then((geoData) => {
|
||||||
if (!mountedRef.current) return;
|
|
||||||
if (latestActionRef.current !== action) return;
|
|
||||||
if (geoData.display_name) {
|
if (geoData.display_name) {
|
||||||
apiFetch(`${API_BASE}/attendance/update-address`, {
|
apiFetch(`${API_BASE}/attendance/update-address`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
latitude: position.coords.latitude,
|
latitude,
|
||||||
longitude: position.coords.longitude,
|
longitude,
|
||||||
address: geoData.display_name,
|
address: geoData.display_name,
|
||||||
punch_action: action,
|
punch_action: action,
|
||||||
}),
|
}),
|
||||||
@@ -238,8 +219,6 @@ export default function Attendance() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
(geoError) => {
|
(geoError) => {
|
||||||
clearTimeout(safetyTimeout);
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
let errorMsg = "Nepodařilo se získat polohu";
|
let errorMsg = "Nepodařilo se získat polohu";
|
||||||
if (geoError.code === geoError.PERMISSION_DENIED) {
|
if (geoError.code === geoError.PERMISSION_DENIED) {
|
||||||
errorMsg = "Přístup k poloze byl zamítnut";
|
errorMsg = "Přístup k poloze byl zamítnut";
|
||||||
@@ -247,10 +226,9 @@ export default function Attendance() {
|
|||||||
errorMsg = "Vypršel časový limit";
|
errorMsg = "Vypršel časový limit";
|
||||||
}
|
}
|
||||||
alert.error(errorMsg);
|
alert.error(errorMsg);
|
||||||
setSubmitting(false);
|
setGpsConfirm({ show: true, action });
|
||||||
setGpsConfirm({ isOpen: true, action });
|
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,29 +237,51 @@ export default function Attendance() {
|
|||||||
gpsData: Record<string, unknown> = {},
|
gpsData: Record<string, unknown> = {},
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const result = await punchMutation.mutateAsync({
|
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||||
punch_action: action,
|
method: "POST",
|
||||||
...gpsData,
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ punch_action: action, ...gpsData }),
|
||||||
});
|
});
|
||||||
|
if (response.status === 401) return;
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
punchTimeoutRef.current = setTimeout(() => {
|
|
||||||
alert.success(result?.message || "Uloženo");
|
if (result.success) {
|
||||||
}, 300);
|
await fetchData();
|
||||||
} catch (e) {
|
setTimeout(() => {
|
||||||
|
alert.success(result.data?.message || result.message || "Uloženo");
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
alert.error(result.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBreak = async () => {
|
const handleBreak = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await punchMutation.mutateAsync({
|
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||||
punch_action: "break_start",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ punch_action: "break_start" }),
|
||||||
});
|
});
|
||||||
alert.success(result?.message || "Přestávka zaznamenána");
|
if (response.status === 401) return;
|
||||||
} catch (e) {
|
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
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í");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -289,22 +289,45 @@ export default function Attendance() {
|
|||||||
|
|
||||||
const handleSaveNotes = async () => {
|
const handleSaveNotes = async () => {
|
||||||
try {
|
try {
|
||||||
await notesMutation.mutateAsync({ notes });
|
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
|
||||||
alert.success("Poznámka byla uložena");
|
method: "POST",
|
||||||
} catch (e) {
|
headers: { "Content-Type": "application/json" },
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
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í");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitchProject = async (newProjectId: string | null) => {
|
const handleSwitchProject = async (newProjectId: string | null) => {
|
||||||
setSwitchingProject(true);
|
setSwitchingProject(true);
|
||||||
try {
|
try {
|
||||||
const result = await switchProjectMutation.mutateAsync({
|
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
|
||||||
project_id: newProjectId ? Number(newProjectId) : null,
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ project_id: newProjectId || null }),
|
||||||
});
|
});
|
||||||
alert.success(result?.message || "Projekt přepnut");
|
if (response.status === 401) return;
|
||||||
} catch (e) {
|
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
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í");
|
||||||
} finally {
|
} finally {
|
||||||
setSwitchingProject(false);
|
setSwitchingProject(false);
|
||||||
}
|
}
|
||||||
@@ -328,43 +351,142 @@ export default function Attendance() {
|
|||||||
const handleRequestSubmit = async () => {
|
const handleRequestSubmit = async () => {
|
||||||
setRequestSubmitting(true);
|
setRequestSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await leaveRequestMutation.mutateAsync(leaveForm);
|
const response = await apiFetch(`${API_BASE}/leave-requests`, {
|
||||||
setIsLeaveModalOpen(false);
|
method: "POST",
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
headers: { "Content-Type": "application/json" },
|
||||||
alert.success(result?.message || "Žádost odeslána");
|
body: JSON.stringify(leaveForm),
|
||||||
setLeaveForm({
|
|
||||||
leave_type: "vacation",
|
|
||||||
date_from: new Date().toISOString().split("T")[0],
|
|
||||||
date_to: new Date().toISOString().split("T")[0],
|
|
||||||
notes: "",
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
if (response.status === 401) return;
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
setShowLeaveModal(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í");
|
||||||
} finally {
|
} finally {
|
||||||
setRequestSubmitting(false);
|
setRequestSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (statusQuery.isPending) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||||
<div className="admin-spinner" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!statusQuery.data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ongoing_shift: ongoingShift,
|
ongoing_shift: ongoingShift,
|
||||||
today_shifts: todayShifts,
|
today_shifts: todayShifts,
|
||||||
leave_balance: leaveBalance,
|
leave_balance: leaveBalance,
|
||||||
} = statusQuery.data;
|
} = 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 isOngoingShift = ongoingShift && !ongoingShift.departure_time;
|
||||||
const completedToday = todayShifts.filter((s) => s.departure_time);
|
const completedToday = todayShifts.filter((s) => s.departure_time);
|
||||||
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
|
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
|
||||||
@@ -454,7 +576,10 @@ export default function Attendance() {
|
|||||||
<div className="attendance-project-header">
|
<div className="attendance-project-header">
|
||||||
<span className="attendance-shift-label">Projekt</span>
|
<span className="attendance-shift-label">Projekt</span>
|
||||||
{activeProjectId ? (
|
{activeProjectId ? (
|
||||||
<span className="admin-badge admin-badge-wrap text-sm">
|
<span
|
||||||
|
className="admin-badge admin-badge-wrap"
|
||||||
|
style={{ fontSize: "0.8125rem" }}
|
||||||
|
>
|
||||||
{projects.find(
|
{projects.find(
|
||||||
(p) => String(p.id) === String(activeProjectId),
|
(p) => String(p.id) === String(activeProjectId),
|
||||||
)
|
)
|
||||||
@@ -462,7 +587,12 @@ export default function Attendance() {
|
|||||||
: `Projekt #${activeProjectId}`}
|
: `Projekt #${activeProjectId}`}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted text-sm">Žádný</span>
|
<span
|
||||||
|
className="text-muted"
|
||||||
|
style={{ fontSize: "0.8125rem" }}
|
||||||
|
>
|
||||||
|
Žádný
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@@ -471,7 +601,8 @@ export default function Attendance() {
|
|||||||
handleSwitchProject(e.target.value || null)
|
handleSwitchProject(e.target.value || null)
|
||||||
}
|
}
|
||||||
disabled={switchingProject}
|
disabled={switchingProject}
|
||||||
className="admin-form-select text-md"
|
className="admin-form-select"
|
||||||
|
style={{ fontSize: "0.875rem" }}
|
||||||
>
|
>
|
||||||
<option value="">— Bez projektu —</option>
|
<option value="">— Bez projektu —</option>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
@@ -483,16 +614,12 @@ export default function Attendance() {
|
|||||||
{projectLogs.length > 0 && (
|
{projectLogs.length > 0 && (
|
||||||
<div className="attendance-project-logs">
|
<div className="attendance-project-logs">
|
||||||
{projectLogs.map((log, i) => {
|
{projectLogs.map((log, i) => {
|
||||||
if (!log.started_at) return null;
|
const start = new Date(log.started_at!);
|
||||||
const start = new Date(log.started_at);
|
|
||||||
const end = log.ended_at
|
const end = log.ended_at
|
||||||
? new Date(log.ended_at)
|
? new Date(log.ended_at)
|
||||||
: new Date();
|
: new Date();
|
||||||
const mins = Math.max(
|
const mins = Math.floor(
|
||||||
0,
|
(end.getTime() - start.getTime()) / 60000,
|
||||||
Math.floor(
|
|
||||||
(end.getTime() - start.getTime()) / 60000,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const h = Math.floor(mins / 60);
|
const h = Math.floor(mins / 60);
|
||||||
const mm = mins % 60;
|
const mm = mins % 60;
|
||||||
@@ -527,7 +654,8 @@ export default function Attendance() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleBreak}
|
onClick={handleBreak}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
Pauza (30 min)
|
Pauza (30 min)
|
||||||
</button>
|
</button>
|
||||||
@@ -535,13 +663,15 @@ export default function Attendance() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePunch("departure")}
|
onClick={() => handlePunch("departure")}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="admin-btn admin-btn-primary w-full"
|
className="admin-btn admin-btn-primary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
{submitting ? "Zpracovávám..." : "Odchod"}
|
{submitting ? "Zpracovávám..." : "Odchod"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsLeaveModalOpen(true)}
|
onClick={() => setShowLeaveModal(true)}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
Žádost o nepřítomnost
|
Žádost o nepřítomnost
|
||||||
</button>
|
</button>
|
||||||
@@ -573,14 +703,16 @@ export default function Attendance() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePunch("arrival")}
|
onClick={() => handlePunch("arrival")}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="admin-btn admin-btn-primary w-full"
|
className="admin-btn admin-btn-primary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
{submitting ? "Zpracovávám..." : "Příchod"}
|
{submitting ? "Zpracovávám..." : "Příchod"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsLeaveModalOpen(true)}
|
onClick={() => setShowLeaveModal(true)}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
Žádost o nepřítomnost
|
Žádost o nepřítomnost
|
||||||
</button>
|
</button>
|
||||||
@@ -623,13 +755,7 @@ export default function Attendance() {
|
|||||||
{formatTime(shift.departure_time)}
|
{formatTime(shift.departure_time)}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{formatMinutes(
|
{formatMinutes(calculateWorkMinutes(shift), true)}
|
||||||
calculateWorkMinutes({
|
|
||||||
...shift,
|
|
||||||
notes: shift.notes ?? undefined,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
{projects.length > 0 && (
|
{projects.length > 0 && (
|
||||||
<td>
|
<td>
|
||||||
@@ -642,12 +768,11 @@ export default function Attendance() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{shiftLogs.map((log, i) => {
|
{shiftLogs.map((log, i) => {
|
||||||
if (!log.started_at) return null;
|
|
||||||
const mins = log.ended_at
|
const mins = log.ended_at
|
||||||
? Math.floor(
|
? Math.floor(
|
||||||
(new Date(log.ended_at).getTime() -
|
(new Date(log.ended_at).getTime() -
|
||||||
new Date(
|
new Date(
|
||||||
log.started_at,
|
log.started_at!,
|
||||||
).getTime()) /
|
).getTime()) /
|
||||||
60000,
|
60000,
|
||||||
)
|
)
|
||||||
@@ -699,7 +824,7 @@ export default function Attendance() {
|
|||||||
{vacationDaysRemaining}
|
{vacationDaysRemaining}
|
||||||
</span>
|
</span>
|
||||||
<span className="attendance-balance-unit">
|
<span className="attendance-balance-unit">
|
||||||
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
|
{pluralizeDays(vacationDaysRemaining)}
|
||||||
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
|
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,10 +877,11 @@ export default function Attendance() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
<div
|
<div
|
||||||
className="text-secondary text-sm"
|
className="text-secondary"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -779,8 +905,8 @@ export default function Attendance() {
|
|||||||
</div>
|
</div>
|
||||||
{data.monthly_fund.leave_hours > 0 && (
|
{data.monthly_fund.leave_hours > 0 && (
|
||||||
<div
|
<div
|
||||||
className="text-muted text-xs"
|
className="text-muted"
|
||||||
style={{ marginTop: "0.375rem" }}
|
style={{ fontSize: "0.75rem", marginTop: "0.375rem" }}
|
||||||
>
|
>
|
||||||
{"Pokryto: "}
|
{"Pokryto: "}
|
||||||
{data.monthly_fund.covered}h (práce{" "}
|
{data.monthly_fund.covered}h (práce{" "}
|
||||||
@@ -789,6 +915,8 @@ export default function Attendance() {
|
|||||||
` + dovolená ${data.monthly_fund.vacation_hours}h`}
|
` + dovolená ${data.monthly_fund.vacation_hours}h`}
|
||||||
{data.monthly_fund.sick_hours > 0 &&
|
{data.monthly_fund.sick_hours > 0 &&
|
||||||
` + nemoc ${data.monthly_fund.sick_hours}h`}
|
` + 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 &&
|
{data.monthly_fund.unpaid_hours > 0 &&
|
||||||
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
|
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
|
||||||
)
|
)
|
||||||
@@ -873,7 +1001,7 @@ export default function Attendance() {
|
|||||||
<path d="M9 18l6-6-6-6" />
|
<path d="M9 18l6-6-6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
{hasPermission("attendance.manage") && (
|
{hasPermission("attendance.admin") && (
|
||||||
<Link to="/attendance/admin" className="attendance-quick-link">
|
<Link to="/attendance/admin" className="attendance-quick-link">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -932,7 +1060,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
{/* Leave Modal */}
|
{/* Leave Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isLeaveModalOpen && (
|
{showLeaveModal && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -942,7 +1070,7 @@ export default function Attendance() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="admin-modal-backdrop"
|
className="admin-modal-backdrop"
|
||||||
onClick={() => setIsLeaveModalOpen(false)}
|
onClick={() => setShowLeaveModal(false)}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal"
|
className="admin-modal"
|
||||||
@@ -1025,15 +1153,15 @@ export default function Attendance() {
|
|||||||
leaveForm.date_to,
|
leaveForm.date_to,
|
||||||
)}
|
)}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
{czechPlural(
|
{(() => {
|
||||||
calculateBusinessDays(
|
const d = calculateBusinessDays(
|
||||||
leaveForm.date_from,
|
leaveForm.date_from,
|
||||||
leaveForm.date_to,
|
leaveForm.date_to,
|
||||||
),
|
);
|
||||||
"pracovní den",
|
if (d === 1) return "pracovní den";
|
||||||
"pracovní dny",
|
if (d >= 2 && d <= 4) return "pracovní dny";
|
||||||
"pracovních dnů",
|
return "pracovních dnů";
|
||||||
)}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted">
|
<span className="text-muted">
|
||||||
{calculateBusinessDays(
|
{calculateBusinessDays(
|
||||||
@@ -1063,7 +1191,7 @@ export default function Attendance() {
|
|||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsLeaveModalOpen(false)}
|
onClick={() => setShowLeaveModal(false)}
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
disabled={requestSubmitting}
|
disabled={requestSubmitting}
|
||||||
>
|
>
|
||||||
@@ -1090,13 +1218,13 @@ export default function Attendance() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={gpsConfirm.isOpen}
|
isOpen={gpsConfirm.show}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setGpsConfirm({ isOpen: false, action: null });
|
setGpsConfirm({ show: false, action: null });
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}}
|
}}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setGpsConfirm({ isOpen: false, action: null });
|
setGpsConfirm({ show: false, action: null });
|
||||||
submitPunch(gpsConfirm.action!, {});
|
submitPunch(gpsConfirm.action!, {});
|
||||||
}}
|
}}
|
||||||
title="GPS nedostupná"
|
title="GPS nedostupná"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
|||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
import { formatMinutes } from "../utils/attendanceHelpers";
|
||||||
|
|
||||||
interface UserTotalData {
|
interface UserTotalData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,34 +18,20 @@ interface UserTotalData {
|
|||||||
working: boolean;
|
working: boolean;
|
||||||
vacation_hours: number;
|
vacation_hours: number;
|
||||||
sick_hours: number;
|
sick_hours: number;
|
||||||
|
holiday_hours: number;
|
||||||
unpaid_hours: number;
|
unpaid_hours: number;
|
||||||
fund: number | null;
|
fund: number | null;
|
||||||
worked_hours: number;
|
worked_hours: number;
|
||||||
covered: number;
|
covered: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
overtime: 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) {
|
function getFundBarBackground(data: UserTotalData) {
|
||||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
if (data.overtime > 0)
|
||||||
// real_worked + vacation + worked_holiday + free_holiday
|
return "linear-gradient(135deg, var(--warning), #d97706)";
|
||||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
if (data.covered >= (data.fund ?? 0))
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
return "linear-gradient(135deg, var(--success), #059669)";
|
||||||
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)";
|
return "var(--gradient)";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +85,9 @@ export default function AttendanceAdmin() {
|
|||||||
useModalLock(showEditModal);
|
useModalLock(showEditModal);
|
||||||
useModalLock(showCreateModal);
|
useModalLock(showCreateModal);
|
||||||
|
|
||||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
if (!hasPermission("attendance.admin")) return <Forbidden />;
|
||||||
|
|
||||||
// Show spinner only on initial load (no data yet), not on filter changes
|
// Show skeleton only on initial load (no data yet), not on filter changes
|
||||||
const isInitialLoad =
|
const isInitialLoad =
|
||||||
loading &&
|
loading &&
|
||||||
data.records.length === 0 &&
|
data.records.length === 0 &&
|
||||||
@@ -109,8 +95,83 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||||
<div className="admin-spinner" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,15 +281,8 @@ export default function AttendanceAdmin() {
|
|||||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||||
const ut = userData as UserTotalData;
|
const ut = userData as UserTotalData;
|
||||||
return (
|
return (
|
||||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
<div key={uid} className="admin-card">
|
||||||
<div
|
<div className="admin-card-body">
|
||||||
className="admin-card-body"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex-row gap-2 mb-2">
|
<div className="flex-row gap-2 mb-2">
|
||||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
||||||
<span
|
<span
|
||||||
@@ -244,11 +298,9 @@ export default function AttendanceAdmin() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
marginBottom: "0.75rem",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "0.4rem",
|
gap: "0.25rem",
|
||||||
minHeight: "2rem",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ut.vacation_hours > 0 && (
|
{ut.vacation_hours > 0 && (
|
||||||
@@ -261,9 +313,9 @@ export default function AttendanceAdmin() {
|
|||||||
Nem: {ut.sick_hours}h
|
Nem: {ut.sick_hours}h
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
{ut.holiday_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-holiday">
|
<span className="attendance-leave-badge badge-holiday">
|
||||||
Sv: {Math.round(ut.svatek)}h
|
Sv: {ut.holiday_hours}h
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ut.unpaid_hours > 0 && (
|
{ut.unpaid_hours > 0 && (
|
||||||
@@ -272,93 +324,62 @@ export default function AttendanceAdmin() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{ut.fund !== null &&
|
{ut.fund !== null && (
|
||||||
(() => {
|
<div className="mt-2">
|
||||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
<div
|
||||||
// (everything the user actually got paid for this month:
|
className="text-secondary"
|
||||||
// raw worked time with sub-day remainders, plus
|
style={{
|
||||||
// vacation days, plus hours worked on holidays, plus
|
display: "flex",
|
||||||
// the 8h per unworked holiday from the fund).
|
justifyContent: "space-between",
|
||||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
alignItems: "center",
|
||||||
const fondUsed =
|
fontSize: "0.8rem",
|
||||||
realWorked +
|
}}
|
||||||
(ut.vacation_hours ?? 0) +
|
>
|
||||||
(ut.worked_holiday_hours ?? 0) +
|
<span>
|
||||||
(ut.svatek ?? 0);
|
Fond: {ut.worked_hours}h / {ut.fund}h
|
||||||
const fundVal = ut.fund ?? 0;
|
</span>
|
||||||
const delta = fondUsed - fundVal;
|
{ut.overtime > 0 && (
|
||||||
return (
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="kpi-card-footer"
|
|
||||||
style={{
|
style={{
|
||||||
marginTop: "auto",
|
height: "100%",
|
||||||
paddingTop: "0.75rem",
|
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
|
||||||
borderTop: "1px solid var(--border-color)",
|
background: getFundBarBackground(ut),
|
||||||
|
borderRadius: "2px",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div
|
</div>
|
||||||
className="text-secondary"
|
</div>
|
||||||
style={{
|
)}
|
||||||
display: "flex",
|
{data.leave_balances[uid] && (
|
||||||
justifyContent: "space-between",
|
<div
|
||||||
alignItems: "center",
|
className="text-secondary"
|
||||||
fontSize: "0.8rem",
|
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
|
||||||
}}
|
>
|
||||||
>
|
Zbývá dovolené:{" "}
|
||||||
<span>
|
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
|
||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
/ {data.leave_balances[uid].vacation_total}h
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -384,7 +405,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<BulkAttendanceModal
|
<BulkAttendanceModal
|
||||||
isOpen={showBulkModal}
|
show={showBulkModal}
|
||||||
onClose={() => setShowBulkModal(false)}
|
onClose={() => setShowBulkModal(false)}
|
||||||
form={bulkForm}
|
form={bulkForm}
|
||||||
setForm={setBulkForm}
|
setForm={setBulkForm}
|
||||||
@@ -397,7 +418,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
<ShiftFormModal
|
<ShiftFormModal
|
||||||
mode="create"
|
mode="create"
|
||||||
isOpen={showCreateModal}
|
show={showCreateModal}
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
form={createForm}
|
form={createForm}
|
||||||
@@ -412,7 +433,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
<ShiftFormModal
|
<ShiftFormModal
|
||||||
mode="edit"
|
mode="edit"
|
||||||
isOpen={showEditModal && !!editingRecord}
|
show={showEditModal && !!editingRecord}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
form={editForm}
|
form={editForm}
|
||||||
@@ -422,14 +443,7 @@ export default function AttendanceAdmin() {
|
|||||||
projectList={projectList}
|
projectList={projectList}
|
||||||
users={data.users}
|
users={data.users}
|
||||||
onShiftDateChange={handleCreateShiftDateChange}
|
onShiftDateChange={handleCreateShiftDateChange}
|
||||||
editingRecord={
|
editingRecord={editingRecord}
|
||||||
editingRecord
|
|
||||||
? {
|
|
||||||
user_name: editingRecord.user_name ?? "",
|
|
||||||
shift_date: editingRecord.shift_date,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -1,29 +1,79 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import FormModal from "../components/FormModal";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import FormField from "../components/FormField";
|
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";
|
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 => {
|
const getVacationClass = (remaining: number): string => {
|
||||||
if (remaining <= 0) return "text-danger";
|
if (remaining <= 0) return "text-danger";
|
||||||
if (remaining < 20) return "text-warning";
|
if (remaining < 20) return "text-warning";
|
||||||
@@ -84,16 +134,23 @@ const getProgressBackground = (
|
|||||||
export default function AttendanceBalances() {
|
export default function AttendanceBalances() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [year, setYear] = useState(new Date().getFullYear());
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
const { data: balancesData, isPending: balancesPending } = useQuery(
|
const [data, setData] = useState<BalancesData>({
|
||||||
attendanceBalancesOptions(year),
|
users: [],
|
||||||
);
|
balances: {},
|
||||||
const { data: fundData, isPending: fundPending } = useQuery(
|
});
|
||||||
attendanceWorkFundOptions(year),
|
|
||||||
);
|
const [fundLoading, setFundLoading] = useState(true);
|
||||||
const { data: projectData, isPending: projectPending } = useQuery(
|
const [fundData, setFundData] = useState<FundData>({
|
||||||
attendanceProjectReportOptions(year),
|
months: {},
|
||||||
);
|
holidays: [],
|
||||||
|
users: [],
|
||||||
|
balances: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [projectLoading, setProjectLoading] = useState(true);
|
||||||
|
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<{
|
const [editingUser, setEditingUser] = useState<{
|
||||||
@@ -112,37 +169,70 @@ export default function AttendanceBalances() {
|
|||||||
userName: string;
|
userName: string;
|
||||||
}>({ show: false, userId: null, userName: "" });
|
}>({ 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(() => {
|
||||||
|
fetchData();
|
||||||
|
fetchFundData();
|
||||||
|
fetchProjectData();
|
||||||
|
}, [fetchData, fetchFundData, fetchProjectData]);
|
||||||
|
|
||||||
|
useModalLock(showEditModal);
|
||||||
|
|
||||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
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) => {
|
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||||||
setEditingUser({ id: userId, name: balance.name });
|
setEditingUser({ id: userId, name: balance.name });
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -156,29 +246,63 @@ export default function AttendanceBalances() {
|
|||||||
const handleEditSubmit = async () => {
|
const handleEditSubmit = async () => {
|
||||||
if (!editingUser) return;
|
if (!editingUser) return;
|
||||||
try {
|
try {
|
||||||
const result = await editMutation.mutateAsync({
|
const response = await apiFetch(
|
||||||
user_id: editingUser.id,
|
`${API_BASE}/attendance?action=balances`,
|
||||||
year,
|
{
|
||||||
action_type: "edit",
|
method: "POST",
|
||||||
...editForm,
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({
|
||||||
setShowEditModal(false);
|
user_id: editingUser.id,
|
||||||
alert.success(result?.message || "Upraveno");
|
year,
|
||||||
} catch (e) {
|
action_type: "edit",
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
...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 handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (!resetConfirm.userId) return;
|
if (!resetConfirm.userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resetMutation.mutateAsync({
|
const response = await apiFetch(
|
||||||
user_id: resetConfirm.userId,
|
`${API_BASE}/attendance?action=balances`,
|
||||||
year,
|
{
|
||||||
action_type: "reset",
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
} catch (e) {
|
body: JSON.stringify({
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
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í");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +314,7 @@ export default function AttendanceBalances() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getYearFundTotals = (userId: string) => {
|
const getYearFundTotals = (userId: string) => {
|
||||||
if (!fundData?.months || Object.keys(fundData.months).length === 0)
|
if (!fundData.months || Object.keys(fundData.months).length === 0)
|
||||||
return null;
|
return null;
|
||||||
let totalFund = 0;
|
let totalFund = 0;
|
||||||
let totalWorked = 0;
|
let totalWorked = 0;
|
||||||
@@ -255,137 +379,132 @@ export default function AttendanceBalances() {
|
|||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
{balancesPending ? (
|
{loading && (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||||
<div className="admin-spinner" />
|
{[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>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Monthly Fund Overview */}
|
{/* Monthly Fund Overview */}
|
||||||
{!fundPending &&
|
{!fundLoading &&
|
||||||
fundData?.months &&
|
fundData.months &&
|
||||||
Object.keys(fundData.months).length > 0 && (
|
Object.keys(fundData.months).length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -467,7 +586,7 @@ export default function AttendanceBalances() {
|
|||||||
gap: "0.375rem",
|
gap: "0.375rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fundData?.users &&
|
{fundData.users &&
|
||||||
fundData.users.map((user) => {
|
fundData.users.map((user) => {
|
||||||
const us = monthData.users?.[String(user.id)];
|
const us = monthData.users?.[String(user.id)];
|
||||||
if (!us) return null;
|
if (!us) return null;
|
||||||
@@ -548,15 +667,23 @@ export default function AttendanceBalances() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fundPending && (
|
{fundLoading && (
|
||||||
<div className="admin-loading">
|
<div className="mt-6">
|
||||||
<div className="admin-spinner" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Monthly Project Overview */}
|
{/* Monthly Project Overview */}
|
||||||
{!projectPending &&
|
{!projectLoading &&
|
||||||
projectData?.months &&
|
projectData.months &&
|
||||||
Object.keys(projectData.months).length > 0 && (
|
Object.keys(projectData.months).length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -748,83 +875,123 @@ export default function AttendanceBalances() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projectPending && (
|
{projectLoading && (
|
||||||
<div className="admin-loading">
|
<div className="mt-6">
|
||||||
<div className="admin-spinner" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
<FormModal
|
<AnimatePresence>
|
||||||
isOpen={showEditModal && !!editingUser}
|
{showEditModal && editingUser && (
|
||||||
onClose={() => setShowEditModal(false)}
|
<motion.div
|
||||||
onSubmit={handleEditSubmit}
|
className="admin-modal-overlay"
|
||||||
title="Upravit dovolenou"
|
initial={{ opacity: 0 }}
|
||||||
loading={editMutation.isPending}
|
animate={{ opacity: 1 }}
|
||||||
>
|
exit={{ opacity: 0 }}
|
||||||
{editingUser && (
|
transition={{ duration: 0.2 }}
|
||||||
<>
|
>
|
||||||
<p
|
<div
|
||||||
className="text-secondary"
|
className="admin-modal-backdrop"
|
||||||
style={{ marginTop: "0", marginBottom: "0.75rem" }}
|
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 }}
|
||||||
>
|
>
|
||||||
{editingUser.name}
|
<div className="admin-modal-header">
|
||||||
</p>
|
<h2 className="admin-modal-title">Upravit dovolenou</h2>
|
||||||
<div className="admin-form">
|
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
|
||||||
<FormField label="Nárok na dovolenou (hodiny)">
|
{editingUser.name}
|
||||||
<input
|
</p>
|
||||||
type="number"
|
</div>
|
||||||
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)">
|
<div className="admin-modal-body">
|
||||||
<input
|
<div className="admin-form">
|
||||||
type="number"
|
<FormField label="Nárok na dovolenou (hodiny)">
|
||||||
value={editForm.vacation_used}
|
<input
|
||||||
onChange={(e) =>
|
type="number"
|
||||||
setEditForm({
|
value={editForm.vacation_total}
|
||||||
...editForm,
|
onChange={(e) =>
|
||||||
vacation_used: parseFloat(e.target.value),
|
setEditForm({
|
||||||
})
|
...editForm,
|
||||||
}
|
vacation_total: parseFloat(e.target.value),
|
||||||
min="0"
|
})
|
||||||
max="500"
|
}
|
||||||
step="0.5"
|
min="0"
|
||||||
className="admin-form-input"
|
max="500"
|
||||||
/>
|
step="1"
|
||||||
</FormField>
|
className="admin-form-input"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Čerpáno nemocenské (hodiny)">
|
<FormField label="Čerpáno dovolené (hodiny)">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editForm.sick_used}
|
value={editForm.vacation_used}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({
|
||||||
...editForm,
|
...editForm,
|
||||||
sick_used: parseFloat(e.target.value),
|
vacation_used: parseFloat(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
min="0"
|
min="0"
|
||||||
max="500"
|
max="500"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</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>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Reset Confirmation */}
|
{/* Reset Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -837,7 +1004,6 @@ export default function AttendanceBalances() {
|
|||||||
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
|
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
|
||||||
confirmText="Resetovat"
|
confirmText="Resetovat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={resetMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { userListOptions } from "../lib/queries/users";
|
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
@@ -9,9 +7,14 @@ import { motion } from "framer-motion";
|
|||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import apiFetch from "../utils/api";
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateForm {
|
interface CreateForm {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
shift_date: string;
|
shift_date: string;
|
||||||
@@ -32,37 +35,47 @@ export default function AttendanceCreate() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: usersData, isPending: loading } = useQuery(userListOptions());
|
const [loading, setLoading] = useState(true);
|
||||||
const users = (usersData ?? []).map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
|
|
||||||
}));
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
|
||||||
const createMutation = useApiMutation<CreateForm, { message?: string }>({
|
const today = new Date().toISOString().split("T")[0];
|
||||||
url: () => `${API_BASE}/attendance`,
|
|
||||||
method: () => "POST",
|
const [form, setForm] = useState<CreateForm>({
|
||||||
invalidate: ["attendance", "users"],
|
user_id: "",
|
||||||
|
shift_date: today,
|
||||||
|
leave_type: "work",
|
||||||
|
leave_hours: 8,
|
||||||
|
arrival_date: today,
|
||||||
|
arrival_time: "",
|
||||||
|
break_start_date: today,
|
||||||
|
break_start_time: "",
|
||||||
|
break_end_date: today,
|
||||||
|
break_end_time: "",
|
||||||
|
departure_date: today,
|
||||||
|
departure_time: "",
|
||||||
|
notes: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateForm>(() => {
|
useEffect(() => {
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const fetchUsers = async () => {
|
||||||
return {
|
try {
|
||||||
user_id: "",
|
const response = await apiFetch(`${API_BASE}/users`);
|
||||||
shift_date: today,
|
const result = await response.json();
|
||||||
leave_type: "work",
|
if (result.success) {
|
||||||
leave_hours: 8,
|
setUsers(
|
||||||
arrival_date: today,
|
Array.isArray(result.data) ? result.data : result.data?.items || [],
|
||||||
arrival_time: "",
|
);
|
||||||
break_start_date: today,
|
}
|
||||||
break_start_time: "",
|
} catch {
|
||||||
break_end_date: today,
|
alert.error("Nepodařilo se načíst uživatele");
|
||||||
break_end_time: "",
|
} finally {
|
||||||
departure_date: today,
|
setLoading(false);
|
||||||
departure_time: "",
|
}
|
||||||
notes: "",
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
fetchUsers();
|
||||||
|
}, [alert]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -75,11 +88,22 @@ export default function AttendanceCreate() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createMutation.mutateAsync(form);
|
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||||
alert.success(result?.message || "Uloženo");
|
method: "POST",
|
||||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
headers: { "Content-Type": "application/json" },
|
||||||
} catch (e) {
|
body: JSON.stringify(form),
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
});
|
||||||
|
|
||||||
|
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í");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -98,12 +122,34 @@ export default function AttendanceCreate() {
|
|||||||
|
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
if (!hasPermission("attendance.admin")) return <Forbidden />;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||||
<div className="admin-spinner" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,6 +223,7 @@ export default function AttendanceCreate() {
|
|||||||
<option value="work">Práce</option>
|
<option value="work">Práce</option>
|
||||||
<option value="vacation">Dovolená</option>
|
<option value="vacation">Dovolená</option>
|
||||||
<option value="sick">Nemoc</option>
|
<option value="sick">Nemoc</option>
|
||||||
|
<option value="holiday">Svátek</option>
|
||||||
<option value="unpaid">Neplacené volno</option>
|
<option value="unpaid">Neplacené volno</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
|
||||||
import {
|
|
||||||
attendanceHistoryOptions,
|
|
||||||
type AttendanceRecord,
|
|
||||||
type ProjectLogEntry,
|
|
||||||
} from "../lib/queries/attendance";
|
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDatetime,
|
formatDatetime,
|
||||||
@@ -20,12 +14,35 @@ import {
|
|||||||
getLeaveTypeBadgeClass,
|
getLeaveTypeBadgeClass,
|
||||||
calculateWorkMinutesPrint,
|
calculateWorkMinutesPrint,
|
||||||
formatTimeOrDatetimePrint,
|
formatTimeOrDatetimePrint,
|
||||||
calculateFreeHolidayHours,
|
|
||||||
holidaysInMonth,
|
|
||||||
normalizeDateStr,
|
|
||||||
formatHoursDecimal,
|
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import FormField from "../components/FormField";
|
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 = [
|
const MONTH_NAMES = [
|
||||||
"Leden",
|
"Leden",
|
||||||
@@ -52,73 +69,6 @@ const formatBreakRange = (record: AttendanceRecord): string => {
|
|||||||
return "—";
|
return "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEasterSunday(year: number): string {
|
|
||||||
const a = year % 19;
|
|
||||||
const b = Math.floor(year / 100);
|
|
||||||
const c = year % 100;
|
|
||||||
const d = Math.floor(b / 4);
|
|
||||||
const e = b % 4;
|
|
||||||
const f = Math.floor((b + 8) / 25);
|
|
||||||
const g = Math.floor((b - f + 1) / 3);
|
|
||||||
const h = (19 * a + b - d - g + 15) % 30;
|
|
||||||
const i = Math.floor(c / 4);
|
|
||||||
const k = c % 4;
|
|
||||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
||||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
||||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
||||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCzechHolidays(year: number): string[] {
|
|
||||||
const y = String(year);
|
|
||||||
const holidays = [
|
|
||||||
`${y}-01-01`,
|
|
||||||
`${y}-05-01`,
|
|
||||||
`${y}-05-08`,
|
|
||||||
`${y}-07-05`,
|
|
||||||
`${y}-07-06`,
|
|
||||||
`${y}-09-28`,
|
|
||||||
`${y}-10-28`,
|
|
||||||
`${y}-11-17`,
|
|
||||||
`${y}-12-24`,
|
|
||||||
`${y}-12-25`,
|
|
||||||
`${y}-12-26`,
|
|
||||||
];
|
|
||||||
const easterSunday = getEasterSunday(year);
|
|
||||||
const easterDate = new Date(easterSunday);
|
|
||||||
const goodFriday = new Date(easterDate);
|
|
||||||
goodFriday.setDate(goodFriday.getDate() - 2);
|
|
||||||
const easterMonday = new Date(easterDate);
|
|
||||||
easterMonday.setDate(easterMonday.getDate() + 1);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
holidays.push(
|
|
||||||
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
|
|
||||||
);
|
|
||||||
holidays.push(
|
|
||||||
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
|
|
||||||
);
|
|
||||||
holidays.sort();
|
|
||||||
return holidays;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBusinessDaysInMonth(year: number, month: number): number {
|
|
||||||
const holidays = getCzechHolidays(year);
|
|
||||||
let count = 0;
|
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
const date = new Date(year, month, day);
|
|
||||||
const dow = date.getDay();
|
|
||||||
if (dow !== 0 && dow !== 6) {
|
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
if (!holidays.includes(dateStr)) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderProjectCell = (record: AttendanceRecord) => {
|
const renderProjectCell = (record: AttendanceRecord) => {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
@@ -135,11 +85,8 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
} else {
|
} else {
|
||||||
isActive = !log.ended_at;
|
isActive = !log.ended_at;
|
||||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||||
const mins = Math.max(
|
const mins = Math.floor(
|
||||||
0,
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||||
Math.floor(
|
|
||||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
h = Math.floor(mins / 60);
|
h = Math.floor(mins / 60);
|
||||||
m = mins % 60;
|
m = mins % 60;
|
||||||
@@ -176,19 +123,48 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AttendanceHistory() {
|
export default function AttendanceHistory() {
|
||||||
|
const alert = useAlert();
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const [loading, setLoading] = useState(true);
|
||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const [companyName, setCompanyName] = useState("");
|
||||||
const companyName = companySettings?.company_name || "";
|
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
});
|
});
|
||||||
const { data, isPending } = useQuery(
|
const [records, setRecords] = useState<AttendanceRecord[]>([]);
|
||||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
|
||||||
);
|
const fetchData = useCallback(async () => {
|
||||||
const records = data ?? [];
|
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 computed = useMemo(() => {
|
const computed = useMemo(() => {
|
||||||
const [yearStr, monthStr] = month.split("-");
|
const [yearStr, monthStr] = month.split("-");
|
||||||
@@ -198,6 +174,7 @@ export default function AttendanceHistory() {
|
|||||||
let totalMinutes = 0;
|
let totalMinutes = 0;
|
||||||
let vacationHours = 0;
|
let vacationHours = 0;
|
||||||
let sickHours = 0;
|
let sickHours = 0;
|
||||||
|
let holidayHours = 0;
|
||||||
let unpaidHours = 0;
|
let unpaidHours = 0;
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
@@ -205,55 +182,41 @@ export default function AttendanceHistory() {
|
|||||||
if (leaveType === "work") {
|
if (leaveType === "work") {
|
||||||
totalMinutes += calculateWorkMinutes(record);
|
totalMinutes += calculateWorkMinutes(record);
|
||||||
} else {
|
} else {
|
||||||
const hours =
|
const hours = Number(record.leave_hours) || 8;
|
||||||
record.leave_hours != null ? Number(record.leave_hours) : 8;
|
|
||||||
if (leaveType === "vacation") vacationHours += hours;
|
if (leaveType === "vacation") vacationHours += hours;
|
||||||
else if (leaveType === "sick") sickHours += hours;
|
else if (leaveType === "sick") sickHours += hours;
|
||||||
|
else if (leaveType === "holiday") holidayHours += hours;
|
||||||
else if (leaveType === "unpaid") unpaidHours += 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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exclude holidays from business days (matching PHP CzechHolidays logic)
|
||||||
const yr = parseInt(yearStr, 10);
|
const yr = parseInt(yearStr, 10);
|
||||||
const mo = parseInt(monthStr, 10) - 1;
|
const mo = parseInt(monthStr, 10) - 1;
|
||||||
const businessDays = getBusinessDaysInMonth(yr, mo);
|
const holidayDays = records.filter(
|
||||||
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
|
(r) => (r.leave_type || "work") === "holiday",
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
).length;
|
||||||
const holidayCount = holidayDates.length;
|
let businessDays = 0;
|
||||||
const fund = (businessDays + holidayCount) * 8;
|
const cur = new Date(yr, mo, 1);
|
||||||
|
while (cur.getMonth() === mo) {
|
||||||
|
const dow = cur.getDay();
|
||||||
|
if (dow !== 0 && dow !== 6) businessDays++;
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
|
||||||
|
businessDays = Math.max(0, businessDays - holidayDays);
|
||||||
|
const fund = businessDays * 8;
|
||||||
const worked = Math.round((totalMinutes / 60) * 100) / 100;
|
const worked = Math.round((totalMinutes / 60) * 100) / 100;
|
||||||
// Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
|
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
|
||||||
const leaveHours = vacationHours + sickHours;
|
const leaveHours = vacationHours + sickHours;
|
||||||
const covered = Math.round((worked + leaveHours) * 100) / 100;
|
const covered = Math.round((worked + leaveHours) * 100) / 100;
|
||||||
|
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
|
||||||
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
|
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
|
||||||
// (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 = {
|
const monthlyFund = {
|
||||||
fund,
|
fund,
|
||||||
business_days: businessDays,
|
business_days: businessDays,
|
||||||
holiday_count: holidayCount,
|
worked,
|
||||||
worked: Math.round(fondUsed * 100) / 100,
|
|
||||||
covered,
|
covered,
|
||||||
remaining,
|
remaining,
|
||||||
overtime,
|
overtime,
|
||||||
@@ -264,12 +227,9 @@ export default function AttendanceHistory() {
|
|||||||
totalMinutes,
|
totalMinutes,
|
||||||
vacationHours,
|
vacationHours,
|
||||||
sickHours,
|
sickHours,
|
||||||
|
holidayHours,
|
||||||
unpaidHours,
|
unpaidHours,
|
||||||
monthlyFund,
|
monthlyFund,
|
||||||
fondUsed,
|
|
||||||
freeHolidayHours,
|
|
||||||
workedHolidayHours,
|
|
||||||
delta,
|
|
||||||
};
|
};
|
||||||
}, [records, month]);
|
}, [records, month]);
|
||||||
|
|
||||||
@@ -353,6 +313,7 @@ export default function AttendanceHistory() {
|
|||||||
}
|
}
|
||||||
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
||||||
.badge-sick { background: #fee2e2; color: #dc2626; }
|
.badge-sick { background: #fee2e2; color: #dc2626; }
|
||||||
|
.badge-holiday { background: #dcfce7; color: #16a34a; }
|
||||||
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
||||||
.badge-overtime { background: #fef3c7; color: #d97706; }
|
.badge-overtime { background: #fef3c7; color: #d97706; }
|
||||||
@media print {
|
@media print {
|
||||||
@@ -439,161 +400,143 @@ export default function AttendanceHistory() {
|
|||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
transition={{ duration: 0.25, delay: 0.08 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
{isPending ? (
|
{loading && (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
|
||||||
<div className="admin-spinner" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
{!loading && computed.monthlyFund && (
|
||||||
{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" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
gap: "1rem",
|
alignItems: "baseline",
|
||||||
flexWrap: "wrap",
|
marginBottom: "0.375rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-icon info">
|
<span
|
||||||
<svg
|
style={{
|
||||||
width="24"
|
fontWeight: 600,
|
||||||
height="24"
|
fontSize: "1rem",
|
||||||
viewBox="0 0 24 24"
|
color: "var(--text-primary)",
|
||||||
fill="none"
|
}}
|
||||||
stroke="currentColor"
|
>
|
||||||
strokeWidth="2"
|
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||||
>
|
{computed.monthlyFund.fund}h
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
</span>
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
<span
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
className="text-secondary"
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
style={{ fontSize: "0.8125rem" }}
|
||||||
</svg>
|
>
|
||||||
</div>
|
{computed.monthlyFund.business_days} prac. dnů
|
||||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
</span>
|
||||||
<div
|
</div>
|
||||||
style={{
|
<div className="attendance-balance-bar">
|
||||||
display: "flex",
|
<div
|
||||||
justifyContent: "space-between",
|
className="attendance-balance-progress"
|
||||||
alignItems: "baseline",
|
style={{
|
||||||
marginBottom: "0.375rem",
|
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||||
}}
|
background:
|
||||||
>
|
computed.monthlyFund.covered >=
|
||||||
<span
|
computed.monthlyFund.fund
|
||||||
style={{
|
? "linear-gradient(135deg, var(--success), #059669)"
|
||||||
fontWeight: 600,
|
: "var(--gradient)",
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
{!computed.monthlyFund && (
|
|
||||||
<div
|
<div
|
||||||
className="text-muted"
|
className="text-muted"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.875rem",
|
display: "flex",
|
||||||
textAlign: "center",
|
justifyContent: "space-between",
|
||||||
padding: "0.5rem 0",
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.375rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Fond měsíce není k dispozici
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -606,89 +549,90 @@ export default function AttendanceHistory() {
|
|||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
transition={{ duration: 0.25, delay: 0.12 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
{isPending ? (
|
{loading && (
|
||||||
<div className="admin-loading">
|
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||||
<div className="admin-spinner" />
|
{[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>
|
||||||
) : (
|
)}
|
||||||
<>
|
{!loading && records.length === 0 && (
|
||||||
{records.length === 0 && (
|
<div className="admin-empty-state">
|
||||||
<div className="admin-empty-state">
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!loading && records.length > 0 && (
|
||||||
{records.length > 0 && (
|
<div className="admin-table-responsive">
|
||||||
<div className="admin-table-responsive">
|
<table className="admin-table">
|
||||||
<table className="admin-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Datum</th>
|
||||||
<th>Datum</th>
|
<th>Typ</th>
|
||||||
<th>Typ</th>
|
<th>Příchod</th>
|
||||||
<th>Příchod</th>
|
<th>Pauza</th>
|
||||||
<th>Pauza</th>
|
<th>Odchod</th>
|
||||||
<th>Odchod</th>
|
<th>Hodiny</th>
|
||||||
<th>Hodiny</th>
|
<th>Projekty</th>
|
||||||
<th>Projekty</th>
|
<th>Poznámka</th>
|
||||||
<th>Poznámka</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{records.map((record) => {
|
||||||
{records.map((record) => {
|
const leaveType = record.leave_type || "work";
|
||||||
const leaveType = record.leave_type || "work";
|
const isLeave = leaveType !== "work";
|
||||||
const isLeave = leaveType !== "work";
|
const workMinutes = isLeave
|
||||||
const workMinutes = isLeave
|
? (Number(record.leave_hours) || 8) * 60
|
||||||
? (Number(record.leave_hours) || 8) * 60
|
: calculateWorkMinutes(record);
|
||||||
: calculateWorkMinutes(record);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{formatDate(record.shift_date)}
|
{formatDate(record.shift_date)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||||
>
|
>
|
||||||
{getLeaveTypeName(leaveType)}
|
{getLeaveTypeName(leaveType)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
{isLeave
|
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||||
? "—"
|
</td>
|
||||||
: formatDatetime(record.arrival_time)}
|
<td className="admin-mono">
|
||||||
</td>
|
{isLeave ? "—" : formatBreakRange(record)}
|
||||||
<td className="admin-mono">
|
</td>
|
||||||
{isLeave ? "—" : formatBreakRange(record)}
|
<td className="admin-mono">
|
||||||
</td>
|
{isLeave
|
||||||
<td className="admin-mono">
|
? "—"
|
||||||
{isLeave
|
: formatDatetime(record.departure_time)}
|
||||||
? "—"
|
</td>
|
||||||
: formatDatetime(record.departure_time)}
|
<td className="admin-mono">
|
||||||
</td>
|
{workMinutes > 0
|
||||||
<td className="admin-mono">
|
? formatMinutes(workMinutes, true)
|
||||||
{workMinutes > 0
|
: "—"}
|
||||||
? formatMinutes(workMinutes, true)
|
</td>
|
||||||
: "—"}
|
<td>{renderProjectCell(record)}</td>
|
||||||
</td>
|
<td
|
||||||
<td>{renderProjectCell(record)}</td>
|
style={{
|
||||||
<td
|
maxWidth: "150px",
|
||||||
style={{
|
overflow: "hidden",
|
||||||
maxWidth: "150px",
|
textOverflow: "ellipsis",
|
||||||
overflow: "hidden",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
}}
|
||||||
whiteSpace: "nowrap",
|
>
|
||||||
}}
|
{record.notes || ""}
|
||||||
>
|
</td>
|
||||||
{record.notes || ""}
|
</tr>
|
||||||
</td>
|
);
|
||||||
</tr>
|
})}
|
||||||
);
|
</tbody>
|
||||||
})}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -737,7 +681,9 @@ export default function AttendanceHistory() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
|
{(computed.vacationHours > 0 ||
|
||||||
|
computed.sickHours > 0 ||
|
||||||
|
computed.holidayHours > 0) && (
|
||||||
<div className="leave-summary">
|
<div className="leave-summary">
|
||||||
{computed.vacationHours > 0 && (
|
{computed.vacationHours > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -753,6 +699,13 @@ export default function AttendanceHistory() {
|
|||||||
</span>{" "}
|
</span>{" "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{computed.holidayHours > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="leave-badge badge-holiday">
|
||||||
|
Svátek: {computed.holidayHours}h
|
||||||
|
</span>{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user