Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
852c127c00 | ||
|
|
71a0016a4a | ||
|
|
88c9b7c2b8 | ||
|
|
4a78d31a7f | ||
|
|
e845400fca | ||
|
|
fce27633a9 | ||
|
|
4d47897e27 | ||
|
|
aef4ab92ec | ||
|
|
5f36c34d48 | ||
|
|
b94f4879e3 | ||
|
|
dab5ad8aa5 | ||
|
|
8b77a0a905 | ||
|
|
4b1c42b17f | ||
|
|
a55356d417 | ||
|
|
c983747418 | ||
|
|
3db257faa9 | ||
|
|
d3d7f7e199 | ||
|
|
084ec9289b | ||
|
|
4a9c283789 | ||
|
|
2f76fe0bc1 | ||
|
|
9a11a8f001 | ||
|
|
f0816b126b | ||
|
|
f31797fb5b | ||
|
|
9c110abf46 | ||
|
|
a0f351b0aa | ||
|
|
916cf34efd | ||
|
|
003807a074 | ||
|
|
ca911bf96c | ||
|
|
9c10ff9f22 | ||
|
|
38a58b55c0 | ||
|
|
4d8fd62961 | ||
|
|
ef404e4362 | ||
|
|
fb022802ae | ||
|
|
795d4d5af9 | ||
|
|
74113035ae | ||
|
|
42f9561337 | ||
|
|
f79967d433 | ||
|
|
d410e7433e | ||
|
|
0711bafac0 | ||
|
|
5273f88272 | ||
|
|
182d4ca4b3 | ||
|
|
4773b5be22 | ||
|
|
6074d6163b | ||
|
|
652e3f91fc |
49
CLAUDE.md
49
CLAUDE.md
@@ -289,6 +289,23 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
|
||||
**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.
|
||||
|
||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
||||
|
||||
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
|
||||
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
|
||||
- Lookup data, default settings, or any other row-level baseline that production needs
|
||||
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
|
||||
|
||||
**What is NOT acceptable:**
|
||||
|
||||
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
|
||||
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
|
||||
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
|
||||
|
||||
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
|
||||
|
||||
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
|
||||
|
||||
### Making schema changes
|
||||
|
||||
```
|
||||
@@ -369,6 +386,8 @@ npx prisma migrate resolve --applied <migration_name>
|
||||
|
||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
||||
|
||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
@@ -387,4 +406,34 @@ npx prisma migrate resolve --applied <migration_name>
|
||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||
- Restart: `pm2 restart app-ts --update-env`
|
||||
|
||||
### Hotfixing a migration that was added after the tarball was built
|
||||
|
||||
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
|
||||
release tarball has already been built and shipped, that migration is
|
||||
NOT in the tarball and `prisma migrate deploy` on prod will report
|
||||
"No pending migrations". The release tarball re-build re-runs the whole
|
||||
release, but for a one-off hotfix you can ship just the new migration
|
||||
folder:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
The migration is still tracked in git and applied via `prisma migrate
|
||||
deploy` — the only thing the tarball is doing is delivering the
|
||||
`migration.sql` to prod, which is the same mechanism the main release
|
||||
uses. This is preferred over running raw SQL on prod (which the policy
|
||||
in "Database Migrations" forbids).
|
||||
|
||||
Do not push directly to production or restart services without confirmation.
|
||||
|
||||
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# Plán prací (Work Schedule) Module Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved
|
||||
**Module:** Work schedule / Rozpis prací for boha-app-ts
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A new module under the "Docházka" sidebar section that lets a foreman plan
|
||||
each employee's work on a per-day basis, weeks or months in advance, and lets
|
||||
every employee see what they (and the team) are supposed to be doing on any
|
||||
given day.
|
||||
|
||||
The reference for the UX is `simon/plan_boha.pdf` — a hand-rolled Excel grid
|
||||
with employees as columns and dates as rows, each cell holding a free-text
|
||||
task description. The module replaces that spreadsheet with a live, audited,
|
||||
permission-aware grid in the existing admin app.
|
||||
|
||||
A plan entry is **what should happen**. It is intentionally separate from
|
||||
attendance (which records **what actually happened**). The two are not
|
||||
auto-linked.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Core
|
||||
|
||||
- Plan entries are stored as **date ranges** (e.g. "PLC upgrade VW USA"
|
||||
from 8.6. to 26.6.), with an optional **per-day override** for the rare
|
||||
case where one day in a range is different (e.g. "Volno po noční").
|
||||
- Each entry has:
|
||||
- `user_id` — the employee this plan is for.
|
||||
- `date_from` / `date_to` — inclusive range.
|
||||
- `project_id` — **optional** FK to the existing `projects` table.
|
||||
- `category` — enum (see below), drives color-coding and reports.
|
||||
- `note` — required free-text task description (max 500 chars).
|
||||
- The project dropdown is populated from the existing `projects` table.
|
||||
No new "task" or "sub-project" entity — the note is the human-readable
|
||||
task description.
|
||||
- A cell can be empty (unassigned / not planned). An empty cell is also
|
||||
clickable for admins — opens the create-entry modal.
|
||||
- A `work_plan_overrides` row covers a single `(user, date)` and takes
|
||||
precedence over the range that would otherwise cover that day.
|
||||
|
||||
### Effective-cell resolution (runtime)
|
||||
|
||||
To compute the cell for user `U` on date `D`:
|
||||
|
||||
1. Look up `work_plan_overrides` for `(U, D)`. If present, use it.
|
||||
2. Otherwise, look up `work_plan_entries` where `user_id = U` and
|
||||
`date_from <= D <= date_to`. If multiple match, return the
|
||||
**last-created** one and write a warning to the audit log.
|
||||
3. Otherwise, the cell is empty.
|
||||
|
||||
### Editing rules
|
||||
|
||||
- **Lock past, free future.** Cells with `shift_date < today` are
|
||||
read-only. Admins can override with `?force=1` on the API; the
|
||||
override is recorded in the audit log.
|
||||
- Today and future are always editable for users with `attendance.manage`.
|
||||
- The modal offers three actions when editing a cell inside an existing
|
||||
range:
|
||||
- "Upravit celý rozsah" — edit the range.
|
||||
- "Upravit pouze tento den" — creates an override.
|
||||
- "Zrušit přiřazení tohoto dne" — creates an override with
|
||||
`category = 'other'` and empty note, leaving the cell empty for
|
||||
that day only.
|
||||
|
||||
### Categories
|
||||
|
||||
A new enum `plan_category` for color-coding and reports:
|
||||
|
||||
| Value | Czech label | Cell color hint |
|
||||
| ------------- | -------------- | --------------- |
|
||||
| `work` | Práce | blue |
|
||||
| `preparation` | Příprava | teal |
|
||||
| `travel` | Cesta / Montáž | amber |
|
||||
| `leave` | Dovolená | green |
|
||||
| `sick` | Nemoc | red |
|
||||
| `training` | Školení | purple |
|
||||
| `other` | Jiné | gray |
|
||||
|
||||
Plan overrides for leave/sick are **informational only**. They do not
|
||||
create or modify `leave_requests` or `attendance` rows. Employees still
|
||||
file leave through the existing `Žádosti` flow.
|
||||
|
||||
### Visibility & permissions
|
||||
|
||||
- **Single sidebar entry** under Docházka: `Plán prací` → `/attendance/plan`.
|
||||
- The sidebar entry is gated by `attendance.manage || attendance.record`
|
||||
(mirrors the existing `Žádosti` entry).
|
||||
- The page is **one** (`PlanWork.tsx`):
|
||||
- If `useAuth().hasPermission('attendance.manage')` → editor mode
|
||||
(grid + edit modal + create/edit/delete affordances).
|
||||
- Otherwise → view-only mode (same grid, read-only detail modal).
|
||||
- All endpoints require `attendance.manage` for writes, and either
|
||||
`attendance.manage` or `attendance.record` for reads. Employees
|
||||
with `attendance.record` are scoped server-side to their own
|
||||
`user_id` for any direct row read; the grid endpoint handles the
|
||||
scoping transparently.
|
||||
- Admin role bypasses all permission checks (existing behavior).
|
||||
|
||||
### Grid
|
||||
|
||||
- Default view is **week** (T23, T24, T25 … matches the PDF's week
|
||||
numbering). A toggle at the top switches to **month** view.
|
||||
- The grid is `employees × dates`. Columns are ordered by
|
||||
role/team first, then alphabetically by name.
|
||||
- Columns are users with the `attendance.record` permission (which
|
||||
includes admin). A "Aktivní" toggle in the toolbar lets the planner
|
||||
hide inactive users.
|
||||
- Each cell shows: a colored chip for the category, the project name
|
||||
(if any) in bold, and the note underneath. Empty cells are blank.
|
||||
- Weekend cells have a subtle yellow background tint to match the
|
||||
PDF's "So 6.6. ★" rows.
|
||||
- Read-only employees see the same grid, but cells are non-clickable
|
||||
for editing. A click opens a read-only detail modal showing project,
|
||||
category, note, "Vytvořil: <user> · <date>" (the entry's
|
||||
`created_by`), and (if the cell is part of a range) "Patří do
|
||||
rozsahu: <date_from> – <date_to>".
|
||||
|
||||
### Audit
|
||||
|
||||
Every create/update/delete of `work_plan_entries` and
|
||||
`work_plan_overrides` writes a row to `audit_logs` with
|
||||
`entity_type = 'work_plan_entry' | 'work_plan_override'` and full
|
||||
`old_values` / `new_values` JSON, following the same pattern as
|
||||
`attendance` and `projects`. The `?force=1` past-edit override is
|
||||
recorded in the description field.
|
||||
|
||||
### Non-goals (out of scope for v1)
|
||||
|
||||
- No automatic link to attendance (plan stays independent).
|
||||
- No time-slot granularity (full days only).
|
||||
- No shift / payroll calculation.
|
||||
- No drag-fill on the grid.
|
||||
- No "task list" entity — projects and free text only.
|
||||
- No notification/email when a plan is changed.
|
||||
- No browser tests (the codebase has none; manual verification in dev).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module placement
|
||||
|
||||
- New module under the **Docházka** sidebar section.
|
||||
- Reuses the existing `attendance.shift_date` concept (date column on
|
||||
the `attendance` table) — the planner lives next to the calendar it
|
||||
informs.
|
||||
- No "administrativa" module exists; the sidebar "Administrativa"
|
||||
section is the commercial back-office (offers, orders, invoices,
|
||||
projects, customers) and is not the right home for HR-style planning.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/admin/plan.ts # Fastify routes
|
||||
├── services/plan.service.ts # Business logic
|
||||
├── schemas/plan.schema.ts # Zod validation
|
||||
└── __tests__/plan.test.ts # Vitest + Supertest tests
|
||||
|
||||
src/admin/
|
||||
├── pages/PlanWork.tsx # The single page (editor or view-only)
|
||||
├── hooks/usePlanWork.ts # State, modal, mutations
|
||||
├── lib/queries/plan.ts # React Query queryOptions
|
||||
└── components/
|
||||
├── PlanGrid.tsx # Week/month grid (shared by both modes)
|
||||
├── PlanCellModal.tsx # Edit modal (create / edit-range / override)
|
||||
├── PlanCellDetailModal.tsx # Read-only detail modal
|
||||
├── PlanRangeChips.tsx # Cell content chip with category color
|
||||
└── plan.css # Module-specific styles only
|
||||
```
|
||||
|
||||
The new CSS file holds only category color helpers and grid-specific
|
||||
overrides. All form/modal/table styling reuses existing classes
|
||||
(`.admin-table`, `.admin-table-sticky`, `FormField`, `FormModal`,
|
||||
`ConfirmModal`, `AdminDatePicker`, etc.) — no new design tokens.
|
||||
|
||||
### React Query keys
|
||||
|
||||
Per the project's "invalidate the full domain" convention:
|
||||
|
||||
- `['plan', 'grid', { dateFrom, dateTo, view }]`
|
||||
- `['plan', 'entries', { userId, dateFrom, dateTo }]`
|
||||
- `['plan', 'overrides', { userId, dateFrom, dateTo }]`
|
||||
- `['plan', 'users']`
|
||||
|
||||
A mutation on any plan row invalidates `['plan']` (covers all four
|
||||
sub-queries by prefix match).
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New enum
|
||||
|
||||
```prisma
|
||||
enum plan_category {
|
||||
work
|
||||
preparation
|
||||
travel
|
||||
leave
|
||||
sick
|
||||
training
|
||||
other
|
||||
}
|
||||
```
|
||||
|
||||
### New table: `work_plan_entries`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------------------ | ------------------------------------------------------- |
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||
| `date_from` | Date | NOT NULL — first day of the range |
|
||||
| `date_to` | Date | NOT NULL — last day of the range, must be ≥ `date_from` |
|
||||
| `project_id` | int? FK → `projects(id)` | nullable — unlinked text entries are allowed |
|
||||
| `category` | enum `plan_category` | NOT NULL, default `'work'` |
|
||||
| `note` | varchar(500) | NOT NULL — free-text task description |
|
||||
| `created_by` | int FK → `users(id)` | who entered it |
|
||||
| `created_at` | DateTime | Prisma default `now()` |
|
||||
| `updated_at` | DateTime | Prisma default `now()` |
|
||||
| `is_deleted` | bool? default false | soft-delete |
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(user_id, date_from)`
|
||||
- `(user_id, date_to)`
|
||||
- `(project_id)`
|
||||
- `(date_from, date_to)` — for cross-user queries like
|
||||
"what's planned for project X this month?"
|
||||
|
||||
### New table: `work_plan_overrides`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------------------ | ------------------------------------------------- |
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||
| `shift_date` | Date | NOT NULL — the one day being overridden |
|
||||
| `project_id` | int? FK → `projects(id)` | nullable |
|
||||
| `category` | enum `plan_category` | NOT NULL — usually `'leave'`, `'sick'`, `'other'` |
|
||||
| `note` | varchar(500) | NOT NULL |
|
||||
| `created_by` | int FK → `users(id)` | |
|
||||
| `created_at` | DateTime | |
|
||||
| `updated_at` | DateTime | |
|
||||
| `is_deleted` | bool? default false | soft-delete |
|
||||
|
||||
Indexes:
|
||||
|
||||
- **Unique** `(user_id, shift_date)` — exactly one non-deleted
|
||||
override per user-day. MySQL unique indexes don't ignore
|
||||
soft-deleted rows, so the service layer enforces "at most one
|
||||
active override per `(user_id, shift_date)`" in application code
|
||||
(the database unique constraint exists as a final safety net and
|
||||
may be temporarily violated by soft-deleted rows, which is
|
||||
acceptable). When restoring a soft-deleted override, the service
|
||||
re-validates that no active override exists for the same day.
|
||||
- `(shift_date)` — for cross-user "what's overridden today" queries.
|
||||
|
||||
### Read semantics (recap)
|
||||
|
||||
For user `U` and date `D`, the "effective" cell is:
|
||||
|
||||
1. Override `(U, D)` if it exists and is not soft-deleted.
|
||||
2. Otherwise the latest `work_plan_entry` whose range covers `D`
|
||||
for user `U` and is not soft-deleted. If multiple match, pick
|
||||
the latest by `created_at` and write a warning to `audit_logs`.
|
||||
3. Otherwise, empty.
|
||||
|
||||
---
|
||||
|
||||
## API Surface
|
||||
|
||||
REST endpoints under `/api/admin/plan`. All routes use the existing
|
||||
Zod-validated, `requirePermission`, `success`/`error` helpers, and
|
||||
`logAudit` pattern. Czech error messages, following project convention.
|
||||
|
||||
| Method | Path | Permission | Body / Query | Returns |
|
||||
| ------ | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| GET | `/plan/grid` | `attendance.manage` OR `attendance.record` | `?date_from=&date_to=&view=week\|month` (range ≤ 92 days) | `{ days, users, cells }` — effective cells ready to render |
|
||||
| GET | `/plan/entries` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_entries` rows in range |
|
||||
| GET | `/plan/overrides` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_overrides` rows in range |
|
||||
| GET | `/plan/users` | `attendance.manage` OR `attendance.record` | — | users with `attendance.record`, ordered by role/team then name |
|
||||
| POST | `/plan/entries` | `attendance.manage` | `{ user_id, date_from, date_to, project_id?, category, note }` | created entry |
|
||||
| PATCH | `/plan/entries/:id` | `attendance.manage` | partial of create body | updated entry |
|
||||
| DELETE | `/plan/entries/:id` | `attendance.manage` | `?force=1` to bypass past-date lock | `{ ok: true }` (soft-delete) |
|
||||
| POST | `/plan/overrides` | `attendance.manage` | `{ user_id, shift_date, project_id?, category, note }` | created override |
|
||||
| PATCH | `/plan/overrides/:id` | `attendance.manage` | partial of create body | updated override |
|
||||
| DELETE | `/plan/overrides/:id` | `attendance.manage` | `?force=1` | `{ ok: true }` (soft-delete) |
|
||||
| GET | `/plan/audit` | `settings.audit` | `?entity_type=&entity_id=` | audit log entries for a row |
|
||||
|
||||
### Past-date lock
|
||||
|
||||
Server-enforced. POST/PATCH/DELETE with any `shift_date` / `date_from`
|
||||
in the past returns 403 with a Czech error message:
|
||||
|
||||
```
|
||||
Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.
|
||||
```
|
||||
|
||||
With `?force=1` and `attendance.manage`, the operation succeeds and
|
||||
the audit log records `description = 'force-edit past date'`.
|
||||
|
||||
### Employee scoping
|
||||
|
||||
`GET /plan/entries` and `GET /plan/overrides` accept `?user_id=`. For
|
||||
a user with only `attendance.record`, the service silently overrides
|
||||
the query to scope to `user_id = auth.user.id`. The grid endpoint
|
||||
returns the same data either way (employees see the full grid in
|
||||
view-only mode).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Detail
|
||||
|
||||
### `PlanWork.tsx`
|
||||
|
||||
- Mounts and calls `useAuth()`. If `hasPermission('attendance.manage')`
|
||||
→ editor state. Otherwise → view-only state.
|
||||
- Renders the toolbar (view toggle, date navigator, active-toggle,
|
||||
print) and `PlanGrid`.
|
||||
- Owns the modal state machine for `PlanCellModal` (editor) or
|
||||
`PlanCellDetailModal` (view).
|
||||
|
||||
### `PlanGrid.tsx`
|
||||
|
||||
- Pure presentational. Takes `users`, `days`, `cells` as props.
|
||||
- Renders a `<table>` with sticky first column (date) and sticky
|
||||
header (employee names).
|
||||
- Each cell is a `<button>` (admin) or `<div>` (view-only) with the
|
||||
colored chip + note.
|
||||
- Calls `onCellClick(userId, date)` for the parent to open the modal.
|
||||
- Reuses `.admin-table` and `.admin-table-sticky` from existing
|
||||
stylesheets.
|
||||
|
||||
### `PlanCellModal.tsx` (editor)
|
||||
|
||||
Three modes, decided by what the user clicked:
|
||||
|
||||
- **Create** — empty cell. Fields: `user_id` (locked to clicked user,
|
||||
can be switched), `date_from` (default = clicked date), `date_to`
|
||||
(default = clicked date; "Jeden den" / "Rozsah dnů" toggle),
|
||||
`project_id` (dropdown, populated from `projects`, nullable),
|
||||
`category` (select), `note` (textarea, required).
|
||||
- **Edit range** — cell is part of an existing range. Loads the
|
||||
full range into the form. "Upravit celý rozsah" button is the
|
||||
primary action.
|
||||
- **Edit day in range** — cell is part of a range. Three buttons:
|
||||
"Upravit celý rozsah" / "Upravit pouze tento den" /
|
||||
"Zrušit přiřazení tohoto dne".
|
||||
- **Edit single-day override** — cell is itself a `work_plan_overrides`
|
||||
row. Standard edit form.
|
||||
- Bottom: delete button (with `ConfirmModal`).
|
||||
|
||||
### `PlanCellDetailModal.tsx` (view-only)
|
||||
|
||||
Read-only. Shows project, category, note, "Vytvořil: <full name of
|
||||
`created_by`> · <`created_at` in local Czech time>", and (if the
|
||||
cell is part of a range) "Patří do rozsahu: <date_from> – <date_to>".
|
||||
For override cells, the "Vytvořil" line refers to the override's own
|
||||
`created_by` (not the parent range's). No edit, no delete.
|
||||
|
||||
### `usePlanWork.ts`
|
||||
|
||||
Owns:
|
||||
|
||||
- `view: 'week' | 'month'`, `anchorDate`, `filterActive`
|
||||
- `effectiveCells` from `['plan', 'grid']` query
|
||||
- `entries` and `overrides` for the cell being edited
|
||||
- `modalState: { mode, userId, date, entryId?, overrideId? }`
|
||||
- `createEntry`, `updateEntry`, `deleteEntry`,
|
||||
`createOverride`, `updateOverride`, `deleteOverride` —
|
||||
React Query mutations that invalidate `['plan']`.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Single new migration:
|
||||
|
||||
`prisma/migrations/<timestamp>_add_work_plan/`
|
||||
|
||||
- `migration.sql`:
|
||||
1. `CREATE TABLE work_plan_entries (...)` with columns and indexes.
|
||||
2. `CREATE TABLE work_plan_overrides (...)` with columns and indexes.
|
||||
3. `CREATE TYPE plan_category` (or in-table enum, per Prisma's
|
||||
MySQL output).
|
||||
|
||||
No data migration. No `seed.ts` change. No new permission keys.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`src/__tests__/plan.test.ts` — Vitest + Supertest against the real
|
||||
test database (`.env.test`):
|
||||
|
||||
1. **Effective-cell resolution** — range + override returns the
|
||||
override for that day, the range for the other days, `null`
|
||||
for uncovered days.
|
||||
2. **Past-date lock** — POST with `date_from < today` returns 403
|
||||
for users without `attendance.manage`; allowed with `?force=1`
|
||||
for users with `attendance.manage`; audit log records the force.
|
||||
3. **Employee self-view scoping** — employee with `attendance.record`
|
||||
calling `GET /plan/entries?user_id=<other>` is scoped to self.
|
||||
4. **Soft-delete + is_deleted** — deleted rows are excluded from
|
||||
`/plan/grid` but kept in `/plan/audit`.
|
||||
5. **Project FK nullability** — entries with `project_id = null`
|
||||
and `category = 'leave'` are valid and render correctly.
|
||||
6. **Range overlap** — two ranges for the same `(user, day)` does
|
||||
not error; the latest one wins; a warning is logged.
|
||||
7. **Audit log on every write** — POST/PATCH/DELETE each insert
|
||||
exactly one `audit_logs` row with `entity_type`,
|
||||
`old_values`, `new_values`.
|
||||
|
||||
---
|
||||
|
||||
## Release Plan
|
||||
|
||||
1. Bump `package.json` version (minor, since new module).
|
||||
2. `npm run build` — confirm clean compile.
|
||||
3. Commit + push to Gitea.
|
||||
4. Tarball + deploy to production via the existing release script
|
||||
(per `CLAUDE.md` "Release Process" section).
|
||||
5. `npx prisma migrate deploy` on prod.
|
||||
6. `pm2 restart app-ts --update-env`.
|
||||
7. Smoke test: log in, open Docházka → Plán prací, add a one-day
|
||||
entry for yourself, reload, confirm it shows.
|
||||
|
||||
The deployment follows the same pattern as the warehouse module
|
||||
release (see commit `5233db2` and the `2026-05-29-warehouse-module`
|
||||
spec for reference).
|
||||
|
||||
---
|
||||
|
||||
## Open items / future work
|
||||
|
||||
- Month view polish if the toggle proves popular.
|
||||
- Drag-fill on the grid (out of scope for v1).
|
||||
- Optional tie-in to attendance: pre-fill `notes` on
|
||||
`AttendanceCreate` from today's plan (read-only hint). Not
|
||||
pursued in v1.
|
||||
- "Patří do rozsahu" link in the detail modal: clickable
|
||||
jump to the range's full detail.
|
||||
- Per-team filtering (if a "team" concept is added to `users` later).
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.6.7",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "1.6.7",
|
||||
"version": "1.9.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Add warehouse module permissions and assign them to the admin role.
|
||||
-- These rows used to be created by `prisma/seed.ts`, which is dev-only and
|
||||
-- must not be run against production (per CLAUDE.md). This migration is
|
||||
-- idempotent: re-running it is a no-op.
|
||||
--
|
||||
-- The 4 permissions match `prisma/seed.ts` lines 284-308 exactly.
|
||||
|
||||
-- 1. Insert the 4 warehouse permissions (INSERT IGNORE skips on duplicate
|
||||
-- name, so re-running the migration is safe).
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('warehouse.view', 'Zobrazit sklad', 'warehouse', 'Prohlížet stav skladu, položky, reporty a historii pohybů', NOW()),
|
||||
('warehouse.operate', 'Příjem a výdej', 'warehouse', 'Vytvářet a potvrzovat příjmy, výdeje a rezervace', NOW()),
|
||||
('warehouse.manage', 'Správa skladu', 'warehouse', 'Spravovat katalog materiálů, dodavatele, lokace a kategorie', NOW()),
|
||||
('warehouse.inventory', 'Inventura', 'warehouse', 'Vytvářet a potvrzovat inventurní sčítkání', NOW());
|
||||
|
||||
-- 2. Assign all 4 permissions to the admin role. INSERT IGNORE on the
|
||||
-- composite primary key (role_id, permission_id) makes this idempotent
|
||||
-- and preserves any other role assignments that already exist.
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN (
|
||||
'warehouse.view',
|
||||
'warehouse.operate',
|
||||
'warehouse.manage',
|
||||
'warehouse.inventory'
|
||||
);
|
||||
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Add work_plan_entries and work_plan_overrides tables plus the plan_category
|
||||
-- enum. Both tables are owned by users (Cascade on delete) and reference
|
||||
-- projects (SetNull). `created_by` uses Restrict so we never silently lose
|
||||
-- the audit trail of who created the entry.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `work_plan_entries` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`date_from` DATE NOT NULL,
|
||||
`date_to` DATE NOT NULL,
|
||||
`project_id` INTEGER NULL,
|
||||
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL DEFAULT 'work',
|
||||
`note` VARCHAR(500) NOT NULL,
|
||||
`created_by` INTEGER NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||
|
||||
INDEX `idx_wpe_user_from`(`user_id`, `date_from`),
|
||||
INDEX `idx_wpe_user_to`(`user_id`, `date_to`),
|
||||
INDEX `idx_wpe_project`(`project_id`),
|
||||
INDEX `idx_wpe_dates`(`date_from`, `date_to`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `work_plan_overrides` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`shift_date` DATE NOT NULL,
|
||||
`project_id` INTEGER NULL,
|
||||
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL,
|
||||
`note` VARCHAR(500) NOT NULL,
|
||||
`created_by` INTEGER NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||
|
||||
INDEX `idx_wpo_date`(`shift_date`),
|
||||
UNIQUE INDEX `uniq_wpo_user_date`(`user_id`, `shift_date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Drop the unique index on (user_id, shift_date) for work_plan_overrides.
|
||||
-- MySQL unique indexes don't ignore soft-deleted rows, which would prevent
|
||||
-- createOverride from soft-deleting an existing override and creating a new
|
||||
-- one for the same (user_id, shift_date). Application code enforces the
|
||||
-- "at most one active override per (user_id, shift_date)" invariant.
|
||||
-- The application-level check is wrapped in a transaction; see
|
||||
-- src/services/plan.service.ts:createOverride.
|
||||
--
|
||||
-- The original index was created in
|
||||
-- 20260605120000_add_work_plan/migration.sql as `uniq_wpo_user_date`.
|
||||
|
||||
-- IMPORTANT ordering: `uniq_wpo_user_date` is the only index covering the
|
||||
-- `user_id` foreign key, and MySQL refuses to drop an index still needed by a
|
||||
-- FK. So we CREATE the replacement non-unique index FIRST (giving the FK
|
||||
-- another covering index), THEN drop the unique one. The reverse order fails
|
||||
-- with "Cannot drop index ... needed in a foreign key constraint" on a fresh
|
||||
-- apply.
|
||||
|
||||
-- Add a non-unique index for the (user_id, shift_date) lookup used by
|
||||
-- createOverride's "is there an active override for this user-day?" query
|
||||
-- and by resolveCell / resolveGrid. This is a plain index, not a unique
|
||||
-- one — multiple rows (active and soft-deleted) can share the same
|
||||
-- (user_id, shift_date) pair.
|
||||
CREATE INDEX `idx_wpo_user_date` ON `work_plan_overrides`(`user_id`, `shift_date`);
|
||||
|
||||
DROP INDEX `uniq_wpo_user_date` ON `work_plan_overrides`;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Add plan_categories table and convert category columns from ENUM to VARCHAR(50)
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `plan_categories` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(50) NOT NULL,
|
||||
`label` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(7) NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `plan_categories_key_key`(`key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_entries
|
||||
ALTER TABLE `work_plan_entries` MODIFY `category` VARCHAR(50) NOT NULL DEFAULT 'work';
|
||||
|
||||
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_overrides
|
||||
ALTER TABLE `work_plan_overrides` MODIFY `category` VARCHAR(50) NOT NULL;
|
||||
|
||||
-- Seed the categories that were previously the plan_category enum
|
||||
INSERT INTO `plan_categories` (`key`, `label`, `color`, `sort_order`, `is_active`) VALUES
|
||||
('work', 'Práce', '#2563eb', 1, true),
|
||||
('preparation', 'Příprava', '#0d9488', 2, true),
|
||||
('travel', 'Cesta / Montáž', '#ca8a04', 3, true),
|
||||
('leave', 'Dovolená', '#16a34a', 4, true),
|
||||
('sick', 'Nemoc', '#dc2626', 5, true),
|
||||
('training', 'Školení', '#7c3aed', 6, true),
|
||||
('other', 'Jiné', '#6b7280', 7, true);
|
||||
@@ -365,6 +365,8 @@ model projects {
|
||||
project_notes project_notes[]
|
||||
sklad_issues sklad_issues[]
|
||||
sklad_reservations sklad_reservations[]
|
||||
work_plan_entries work_plan_entries[]
|
||||
work_plan_overrides work_plan_overrides[]
|
||||
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
|
||||
@@ -591,6 +593,10 @@ model users {
|
||||
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
|
||||
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
|
||||
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
|
||||
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
|
||||
work_plan_entries_owned work_plan_entries[]
|
||||
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
|
||||
work_plan_overrides_owned work_plan_overrides[]
|
||||
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
|
||||
|
||||
@@index([is_active], map: "idx_users_is_active")
|
||||
@@ -917,3 +923,55 @@ model sklad_inventory_lines {
|
||||
@@index([session_id], map: "sklad_inventory_lines_session_id")
|
||||
@@map("sklad_inventory_lines")
|
||||
}
|
||||
|
||||
model plan_categories {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique @db.VarChar(50)
|
||||
label String @db.VarChar(100)
|
||||
color String @db.VarChar(7)
|
||||
sort_order Int @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
updated_at DateTime? @default(now()) @updatedAt @db.DateTime(0)
|
||||
}
|
||||
|
||||
model work_plan_entries {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
date_from DateTime @db.Date
|
||||
date_to DateTime @db.Date
|
||||
project_id Int?
|
||||
category String @default("work") @db.VarChar(50)
|
||||
note String @db.VarChar(500)
|
||||
created_by Int
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
|
||||
@@index([user_id, date_from], map: "idx_wpe_user_from")
|
||||
@@index([user_id, date_to], map: "idx_wpe_user_to")
|
||||
@@index([project_id], map: "idx_wpe_project")
|
||||
@@index([date_from, date_to], map: "idx_wpe_dates")
|
||||
}
|
||||
|
||||
model work_plan_overrides {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
shift_date DateTime @db.Date
|
||||
project_id Int?
|
||||
category String @db.VarChar(50)
|
||||
note String @db.VarChar(500)
|
||||
created_by Int
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
|
||||
@@index([user_id, shift_date], map: "idx_wpo_user_date")
|
||||
@@index([shift_date], map: "idx_wpo_date")
|
||||
}
|
||||
|
||||
886
src/__tests__/plan.test.ts
Normal file
886
src/__tests__/plan.test.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import planRoutes from "../routes/admin/plan";
|
||||
import {
|
||||
resolveCell,
|
||||
resolveGrid,
|
||||
listPlanUsers,
|
||||
assertNotPastDate,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
listEntries,
|
||||
listOverrides,
|
||||
} from "../services/plan.service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Note-prefix for test-created rows so we can clean them up safely. */
|
||||
const N = "wh_plan_";
|
||||
|
||||
let adminUserId: number;
|
||||
let noPermUserId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Pick the first admin user from the test database as our fixture.
|
||||
// The users -> roles relation is named `roles` in the Prisma schema
|
||||
// (users.role_id references roles.id).
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
|
||||
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
||||
// for create tests. Just pick the second user.
|
||||
const allUsers = await prisma.users.findMany({ take: 2 });
|
||||
noPermUserId =
|
||||
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any leftover test data from previous runs. We only touch rows
|
||||
// whose `note` contains our test prefix, so we never disturb real data.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Final cleanup so we never leave soft-deleted test rows behind.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveCell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveCell", () => {
|
||||
it("returns null when nothing covers the date", async () => {
|
||||
// No entry, no override for (adminUserId, "2099-01-01")
|
||||
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the entry that covers the date", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}PLC upgrade`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("entry");
|
||||
expect(result?.note).toBe(`${N}PLC upgrade`);
|
||||
expect(result?.category).toBe("work");
|
||||
expect(result?.rangeFrom).toBe("2099-06-01");
|
||||
expect(result?.rangeTo).toBe("2099-06-10");
|
||||
});
|
||||
|
||||
it("returns the override for that day, not the entry", async () => {
|
||||
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
||||
// takes precedence.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}PLC upgrade`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-05"),
|
||||
category: "leave",
|
||||
note: `${N}Volno po noční`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("override");
|
||||
expect(result?.note).toBe(`${N}Volno po noční`);
|
||||
expect(result?.category).toBe("leave");
|
||||
});
|
||||
|
||||
it("ignores soft-deleted entries and overrides", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}deleted entry`,
|
||||
is_deleted: true,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveGrid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveGrid", () => {
|
||||
it("returns a 2D map keyed by userId then date", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-03"),
|
||||
category: "work",
|
||||
note: `${N}A`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
|
||||
});
|
||||
|
||||
it("applies override on a covered day", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-03"),
|
||||
category: "work",
|
||||
note: `${N}A`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-02"),
|
||||
category: "leave",
|
||||
note: `${N}B`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listPlanUsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.listPlanUsers", () => {
|
||||
it("returns at least the admin user", async () => {
|
||||
const users = await listPlanUsers();
|
||||
const found = users.find((u) => u.id === adminUserId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.has_attendance_record).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assertNotPastDate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.assertNotPastDate", () => {
|
||||
it("returns error for a past date", () => {
|
||||
const result = assertNotPastDate("2000-01-01", false);
|
||||
expect(result).toEqual({
|
||||
error:
|
||||
"Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a future date", () => {
|
||||
const result = assertNotPastDate("2099-01-01", false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a past date with force=true", () => {
|
||||
const result = assertNotPastDate("2000-01-01", true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createEntry", () => {
|
||||
it("creates an entry and returns { data, oldData: null }", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-01",
|
||||
date_to: "2099-07-05",
|
||||
category: "work",
|
||||
note: `${N}new entry`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}new entry`);
|
||||
expect(result.oldData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
category: "work",
|
||||
note: `${N}past`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows a past date with force", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
category: "work",
|
||||
note: `${N}past forced`,
|
||||
},
|
||||
adminUserId,
|
||||
true,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when date_to < date_from", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-10",
|
||||
date_to: "2099-07-05",
|
||||
category: "work",
|
||||
note: `${N}invalid range`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an empty note (text is optional)", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-15",
|
||||
date_to: "2099-07-15",
|
||||
category: "work",
|
||||
note: "",
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a range whose date_to is in the past, even if date_from is today", async () => {
|
||||
// The range check `date_to >= date_from` fires before the past-date
|
||||
// check, so a backwards range returns 400 (correct UX). The to-endpoint
|
||||
// past-date check matters when updating an existing entry — see the
|
||||
// matching test in updateEntry below. Here we just confirm that a
|
||||
// range whose start is in the past is rejected as 403.
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-05",
|
||||
category: "work",
|
||||
note: `${N}past-both`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.updateEntry", () => {
|
||||
it("updates the note and returns the old data", async () => {
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-08-01",
|
||||
date_to: "2099-08-03",
|
||||
category: "work",
|
||||
note: `${N}original`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
|
||||
const updated = await updateEntry(
|
||||
created.data.id,
|
||||
{ note: `${N}updated` },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an update that pushes date_to into the past", async () => {
|
||||
// Future entry, but the user tries to extend its end backwards into
|
||||
// the past. The to-endpoint past-date check should fire.
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-08-10",
|
||||
date_to: "2099-08-15",
|
||||
category: "work",
|
||||
note: `${N}to-shorten`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
const updated = await updateEntry(
|
||||
created.data.id,
|
||||
{ date_to: "2000-01-05" },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in updated).toBe(true);
|
||||
if ("error" in updated) expect(updated.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.deleteEntry", () => {
|
||||
it("soft-deletes the row and returns the old data", async () => {
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-09-01",
|
||||
date_to: "2099-09-01",
|
||||
category: "work",
|
||||
note: `${N}to delete`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
|
||||
const result = await deleteEntry(created.data.id, adminUserId, false);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect((result.oldData as any).note).toBe(`${N}to delete`);
|
||||
}
|
||||
|
||||
const stillThere = await prisma.work_plan_entries.findUnique({
|
||||
where: { id: created.data.id },
|
||||
});
|
||||
expect(stillThere?.is_deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createOverride", () => {
|
||||
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-01",
|
||||
category: "leave",
|
||||
note: `${N}day off`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}day off`);
|
||||
expect(result.oldData).toBeNull();
|
||||
expect(result.replacedData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
||||
await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}first`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const second = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "sick",
|
||||
note: `${N}second`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in second).toBe(true);
|
||||
if ("data" in second) {
|
||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
||||
}
|
||||
|
||||
const all = await prisma.work_plan_overrides.findMany({
|
||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
||||
});
|
||||
// The first should be soft-deleted, the second active
|
||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2000-01-01",
|
||||
category: "leave",
|
||||
note: `${N}past`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.updateOverride", () => {
|
||||
it("updates the note and returns the old data", async () => {
|
||||
const created = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-11-01",
|
||||
category: "leave",
|
||||
note: `${N}original`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup");
|
||||
const updated = await updateOverride(
|
||||
created.data.id,
|
||||
{ note: `${N}updated` },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.deleteOverride", () => {
|
||||
it("soft-deletes the override and returns the old data", async () => {
|
||||
const created = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-12-01",
|
||||
category: "leave",
|
||||
note: `${N}to delete`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup");
|
||||
const result = await deleteOverride(created.data.id, adminUserId, false);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect((result.oldData as any).note).toBe(`${N}to delete`);
|
||||
}
|
||||
const still = await prisma.work_plan_overrides.findUnique({
|
||||
where: { id: created.data.id },
|
||||
});
|
||||
expect(still?.is_deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listEntries / listOverrides (employee scoping)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.listEntries (employee scoping)", () => {
|
||||
it("returns entries for the actor user when scoped", async () => {
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2098-01-01",
|
||||
date_to: "2098-01-05",
|
||||
category: "work",
|
||||
note: `${N}scope test`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-01-01", date_to: "2098-01-31" },
|
||||
adminUserId,
|
||||
true, // isAdmin
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("scopes non-admin to actor user_id when querying for another user", async () => {
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2098-02-01",
|
||||
date_to: "2098-02-05",
|
||||
category: "work",
|
||||
note: `${N}admin entry`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||
noPermUserId,
|
||||
false, // not admin
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
it("scopes non-admin to actor user_id", async () => {
|
||||
await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-01",
|
||||
category: "leave",
|
||||
note: `${N}admin override`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listOverrides(
|
||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||
noPermUserId,
|
||||
false,
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP route tests (Fastify inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a Fastify app with only the plan routes registered under
|
||||
// /api/admin/plan. rateLimit max=1000 keeps the 7 tests from tripping it.
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminToken: string;
|
||||
let noPermToken: string;
|
||||
let noPermRoleId: number;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the
|
||||
* DB via `loadAuthData(payload.sub)`, so the `role` claim is ignored —
|
||||
* only the `sub` (user id) matters for these tests.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authGet(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function authPost(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authPatch(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PATCH",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authDelete(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "DELETE",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Re-fetch the admin user with the roles relation so we can build a token.
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("admin user not found in test setup");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Create a no-permission role + user; the role has zero permissions so
|
||||
// any permission check returns 403.
|
||||
const stamp = Date.now();
|
||||
const noPermRole = await prisma.roles.create({
|
||||
data: {
|
||||
name: `noperm_plan_${stamp}`,
|
||||
display_name: "No Perm Plan Test",
|
||||
},
|
||||
});
|
||||
noPermRoleId = noPermRole.id;
|
||||
const noPermUser = await prisma.users.create({
|
||||
data: {
|
||||
username: `noperm_plan_${stamp}`,
|
||||
first_name: "No",
|
||||
last_name: "Perm",
|
||||
email: `noperm_plan_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: noPermRole.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
noPermUserId = noPermUser.id;
|
||||
noPermToken = generateToken({
|
||||
id: noPermUser.id,
|
||||
username: noPermUser.username,
|
||||
roleName: noPermRole.name,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
if (noPermUserId) {
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: noPermUserId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
if (noPermRoleId) {
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: noPermRoleId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("HTTP /api/admin/plan", () => {
|
||||
it("GET /users requires auth", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/plan/users",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("GET /users returns plan users for an admin", async () => {
|
||||
const res = await authGet("/api/admin/plan/users", adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /grid returns days, users, cells", async () => {
|
||||
// Seed an entry so the cell on 2097-06-01 has a known note.
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-06-01",
|
||||
date_to: "2097-06-05",
|
||||
category: "work",
|
||||
note: `${N}grid test`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const res = await authGet(
|
||||
"/api/admin/plan/grid?date_from=2097-06-01&date_to=2097-06-05",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.users).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
|
||||
`${N}grid test`,
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /grid rejects ranges > 92 days", async () => {
|
||||
const res = await authGet(
|
||||
"/api/admin/plan/grid?date_from=2097-01-01&date_to=2097-12-31",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /entries requires attendance.manage", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", noPermToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-07-01",
|
||||
date_to: "2097-07-01",
|
||||
category: "work",
|
||||
note: `${N}forbidden`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /entries works for admin", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-08-01",
|
||||
date_to: "2097-08-01",
|
||||
category: "work",
|
||||
note: `${N}http create`,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.note).toBe(`${N}http create`);
|
||||
});
|
||||
|
||||
it("POST /entries accepts an empty note (note is optional)", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-08-05",
|
||||
date_to: "2097-08-05",
|
||||
category: "work",
|
||||
note: "",
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("POST /entries rejects a past date without ?force=1", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-02-01",
|
||||
date_to: "2000-02-01",
|
||||
category: "work",
|
||||
note: `${N}past http`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /overrides with force=1 allows past date", async () => {
|
||||
const res = await authPost(
|
||||
"/api/admin/plan/overrides?force=1",
|
||||
adminToken,
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2000-01-15",
|
||||
category: "leave",
|
||||
note: `${N}forced past`,
|
||||
},
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
95
src/__tests__/planAuditDescription.test.ts
Normal file
95
src/__tests__/planAuditDescription.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
planCategoryLabel,
|
||||
buildPlanAuditDescription,
|
||||
} from "../utils/planAuditDescription";
|
||||
|
||||
describe("planCategoryLabel", () => {
|
||||
it("maps known plan categories to Czech labels", () => {
|
||||
expect(planCategoryLabel("work")).toBe("Práce");
|
||||
expect(planCategoryLabel("preparation")).toBe("Příprava");
|
||||
expect(planCategoryLabel("travel")).toBe("Cesta / Montáž");
|
||||
expect(planCategoryLabel("leave")).toBe("Dovolená");
|
||||
expect(planCategoryLabel("sick")).toBe("Nemoc");
|
||||
expect(planCategoryLabel("training")).toBe("Školení");
|
||||
expect(planCategoryLabel("other")).toBe("Jiné");
|
||||
});
|
||||
|
||||
it("falls back to the raw value for an unknown category", () => {
|
||||
expect(planCategoryLabel("unknown_xyz")).toBe("unknown_xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPlanAuditDescription", () => {
|
||||
it("describes a multi-day entry with project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: "Rekonstrukce haly",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-12",
|
||||
});
|
||||
expect(desc).toBe(
|
||||
"Jan Novák · 08.06.2026 – 12.06.2026 · Práce · Rekonstrukce haly",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a single-day range to one date", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: "Rekonstrukce haly",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-08",
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce · Rekonstrukce haly");
|
||||
});
|
||||
|
||||
it("omits the project segment when there is no project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Petr Svoboda",
|
||||
categoryLabel: "Dovolená",
|
||||
projectName: null,
|
||||
dateFrom: "2026-06-10",
|
||||
dateTo: "2026-06-10",
|
||||
});
|
||||
expect(desc).toBe("Petr Svoboda · 10.06.2026 · Dovolená");
|
||||
});
|
||||
|
||||
it("appends the emergency-edit note when force is set", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: null,
|
||||
dateFrom: "2026-01-02",
|
||||
dateTo: "2026-01-02",
|
||||
force: true,
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 02.01.2026 · Práce · nouzová úprava");
|
||||
});
|
||||
|
||||
it("appends a custom suffix (e.g. replaced override)", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Nemoc",
|
||||
projectName: null,
|
||||
dateFrom: "2026-06-10",
|
||||
dateTo: "2026-06-10",
|
||||
suffix: "nahrazeno novým záznamem",
|
||||
});
|
||||
expect(desc).toBe(
|
||||
"Jan Novák · 10.06.2026 · Nemoc · nahrazeno novým záznamem",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a whitespace-only project name as no project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: " ",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-08",
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce");
|
||||
});
|
||||
});
|
||||
129
src/__tests__/planCategory.test.ts
Normal file
129
src/__tests__/planCategory.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
listPlanCategories,
|
||||
createPlanCategory,
|
||||
updatePlanCategory,
|
||||
deactivatePlanCategory,
|
||||
deletePlanCategory,
|
||||
isCategoryKeyActive,
|
||||
slugifyCategory,
|
||||
} from "../services/planCategory.service";
|
||||
|
||||
const created: number[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("slugifyCategory", () => {
|
||||
it("strips Czech diacritics and non-alphanumerics", () => {
|
||||
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
||||
expect(slugifyCategory("Příprava")).toBe("priprava");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan category service", () => {
|
||||
it("seeds are present and ordered", async () => {
|
||||
const cats = await listPlanCategories(true);
|
||||
const keys = cats.map((c) => c.key);
|
||||
expect(keys).toContain("work");
|
||||
expect(keys).toContain("other");
|
||||
});
|
||||
|
||||
it("creates a category with a unique slug and next sort_order", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Kategorie ZZ",
|
||||
color: "#123456",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
expect(res.data.key).toBe("test_kategorie_zz");
|
||||
expect(res.data.color).toBe("#123456");
|
||||
expect(res.data.is_active).toBe(true);
|
||||
expect(await isCategoryKeyActive("test_kategorie_zz")).toBe(true);
|
||||
});
|
||||
|
||||
it("dedupes a colliding slug", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Kategorie ZZ",
|
||||
color: "#222222",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
expect(res.data.key).toBe("test_kategorie_zz_2");
|
||||
});
|
||||
|
||||
it("updates label and color (key immutable)", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Upd ZZ",
|
||||
color: "#aaaaaa",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
const upd = await updatePlanCategory(res.data.id, {
|
||||
label: "Test Upd2 ZZ",
|
||||
color: "#bbbbbb",
|
||||
});
|
||||
if ("error" in upd) throw new Error(upd.error);
|
||||
expect(upd.data.label).toBe("Test Upd2 ZZ");
|
||||
expect(upd.data.color).toBe("#bbbbbb");
|
||||
expect(upd.data.key).toBe("test_upd_zz");
|
||||
});
|
||||
|
||||
it("deactivate hides the key from active check", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Deact ZZ",
|
||||
color: "#cccccc",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
await deactivatePlanCategory(res.data.id);
|
||||
expect(await isCategoryKeyActive("test_deact_zz")).toBe(false);
|
||||
});
|
||||
|
||||
it("hard-deletes an unused category", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Del ZZ",
|
||||
color: "#dddddd",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
const del = await deletePlanCategory(res.data.id);
|
||||
expect("data" in del).toBe(true);
|
||||
const stillThere = await prisma.plan_categories.findUnique({
|
||||
where: { id: res.data.id },
|
||||
});
|
||||
expect(stillThere).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to hard-delete a category in use", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test InUse ZZ",
|
||||
color: "#eeeeee",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||
if (!user) throw new Error("no user in test DB");
|
||||
const entry = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
date_from: new Date("2026-06-10T00:00:00.000Z"),
|
||||
date_to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
category: res.data.key,
|
||||
note: "test in-use",
|
||||
created_by: user.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const del = await deletePlanCategory(res.data.id);
|
||||
expect("error" in del).toBe(true);
|
||||
if ("error" in del) expect(del.status).toBe(409);
|
||||
} finally {
|
||||
await prisma.work_plan_entries.delete({ where: { id: entry.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./plan.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
@@ -37,6 +38,7 @@ const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
||||
const Trips = lazy(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||
@@ -128,6 +130,7 @@ export default function AdminApp() {
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
/* 1px transparent border on ALL buttons so switching between primary
|
||||
and secondary doesn't cause a 1px layout shift (the border is always
|
||||
1px — only the colour changes). */
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
@@ -38,11 +41,13 @@
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
|
||||
}
|
||||
@@ -75,6 +80,24 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
border-color: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.admin-btn-danger .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
210
src/admin/components/PlanCategoriesModal.tsx
Normal file
210
src/admin/components/PlanCategoriesModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from "react";
|
||||
import FormModal from "./FormModal";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { PlanCategory } from "../lib/queries/plan";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PlanCategory[];
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_COLOR = "#2563eb";
|
||||
|
||||
export default function PlanCategoriesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
}: Props) {
|
||||
const alert = useAlert();
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);
|
||||
// Id of the row showing the inline "really delete?" confirm, or null.
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
|
||||
const createCat = useApiMutation<
|
||||
{ label: string; color: string },
|
||||
PlanCategory
|
||||
>({
|
||||
url: "/api/admin/plan/categories",
|
||||
method: "POST",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
const updateCat = useApiMutation<
|
||||
{ id: number; label?: string; color?: string; is_active?: boolean },
|
||||
PlanCategory
|
||||
>({
|
||||
url: (v) => `/api/admin/plan/categories/${v.id}`,
|
||||
method: "PATCH",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
const deleteCat = useApiMutation<{ id: number }, { ok: true }>({
|
||||
url: (v) => `/api/admin/plan/categories/${v.id}`,
|
||||
method: "DELETE",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
const label = newLabel.trim();
|
||||
if (!label) {
|
||||
alert.error("Zadejte název kategorie");
|
||||
return;
|
||||
}
|
||||
createCat.mutate(
|
||||
{ label, color: newColor },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewLabel("");
|
||||
setNewColor(DEFAULT_NEW_COLOR);
|
||||
},
|
||||
onError: (e) => alert.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const patch = (
|
||||
id: number,
|
||||
body: { label?: string; color?: string; is_active?: boolean },
|
||||
) => {
|
||||
updateCat.mutate(
|
||||
{ id, ...body },
|
||||
{ onError: (e) => alert.error(e.message) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
deleteCat.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => setConfirmDeleteId(null),
|
||||
// Server blocks deleting a category that is still in use (409) — show
|
||||
// the Czech message ("…můžete ji skrýt") and keep the row.
|
||||
onError: (e) => {
|
||||
alert.error(e.message);
|
||||
setConfirmDeleteId(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const busy =
|
||||
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Správa kategorií"
|
||||
hideFooter
|
||||
size="md"
|
||||
>
|
||||
<div className="plan-cat-manager">
|
||||
{categories.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
// Uncontrolled + patch on blur (like the rename input): the
|
||||
// native color picker fires onChange on every drag tick, which
|
||||
// would spam PATCH requests and the audit log. onBlur commits
|
||||
// once when the picker closes.
|
||||
defaultValue={c.color}
|
||||
key={c.color}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== c.color) {
|
||||
patch(c.id, { color: e.target.value });
|
||||
}
|
||||
}}
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.label) patch(c.id, { label: v });
|
||||
}}
|
||||
aria-label={`Název – ${c.label}`}
|
||||
/>
|
||||
{confirmDeleteId === c.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => handleDelete(c.id)}
|
||||
>
|
||||
Opravdu smazat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||
>
|
||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label={`Smazat – ${c.label}`}
|
||||
title="Smazat kategorii"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="plan-cat-row plan-cat-row--new">
|
||||
<input
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
value={newColor}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
aria-label="Barva nové kategorie"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Nová kategorie…"
|
||||
value={newLabel}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
482
src/admin/components/PlanCellModal.tsx
Normal file
482
src/admin/components/PlanCellModal.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import FormField from "./FormField";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import useReducedMotion from "../hooks/useReducedMotion";
|
||||
import {
|
||||
ResolvedCell,
|
||||
PlanCategory,
|
||||
planCategoryLabel,
|
||||
cellProjectLabel,
|
||||
} from "../lib/queries/plan";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
project_number?: string;
|
||||
}
|
||||
|
||||
export type PlanCellModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| {
|
||||
kind: "edit-range";
|
||||
entryId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
range: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "edit-override";
|
||||
overrideId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
cell: ResolvedCell;
|
||||
}
|
||||
| {
|
||||
kind: "day-in-range";
|
||||
entryId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
range: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: PlanCellModalMode;
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
onClose: () => void;
|
||||
onSaveEntry: (body: any) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
||||
onDeleteEntry: (id: number) => Promise<void>;
|
||||
onSaveOverride: (body: any) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
||||
onDeleteOverride: (id: number) => Promise<void>;
|
||||
onCreateOverrideFromRange: (
|
||||
entryId: number,
|
||||
userId: number,
|
||||
date: string,
|
||||
) => Promise<void>;
|
||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||
}
|
||||
|
||||
export default function PlanCellModal(props: Props) {
|
||||
const { open, mode, onClose } = props;
|
||||
if (!open || mode.kind === "closed") return null;
|
||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||
if (mode.kind === "day-in-range")
|
||||
return <DayInRangeModal {...props} mode={mode} />;
|
||||
return <EditForm {...props} />;
|
||||
}
|
||||
|
||||
function EditForm(props: Props) {
|
||||
const {
|
||||
mode,
|
||||
onClose,
|
||||
onSaveEntry,
|
||||
onUpdateEntry,
|
||||
onDeleteEntry,
|
||||
onSaveOverride,
|
||||
onUpdateOverride,
|
||||
onDeleteOverride,
|
||||
projects,
|
||||
} = props;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
||||
// the parameter type is the full union, so we narrow explicitly here.
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "edit-range" &&
|
||||
mode.kind !== "edit-override"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOverride = mode.kind === "edit-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
|
||||
const initial = (() => {
|
||||
if (mode.kind === "create") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: null as number | null,
|
||||
// Default to "work" when it's active, otherwise the first active
|
||||
// category, so a new entry never starts on a retired key.
|
||||
category: activeCategories.some((c) => c.key === "work")
|
||||
? "work"
|
||||
: (activeCategories[0]?.key ?? "work"),
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
if (mode.kind === "edit-range") {
|
||||
return {
|
||||
date_from: mode.range.date_from,
|
||||
date_to: mode.range.date_to,
|
||||
is_range: mode.range.date_from !== mode.range.date_to,
|
||||
project_id: mode.range.project_id,
|
||||
category: mode.range.category,
|
||||
note: mode.range.note,
|
||||
};
|
||||
}
|
||||
// mode.kind === "edit-override" (narrowed above)
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: mode.cell.project_id,
|
||||
category: mode.cell.category,
|
||||
note: mode.cell.note,
|
||||
};
|
||||
})();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||
const [isRange, setIsRange] = useState(initial.is_range);
|
||||
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
|
||||
const [category, setCategory] = useState(initial.category);
|
||||
const [note, setNote] = useState(initial.note);
|
||||
|
||||
const title =
|
||||
mode.kind === "create"
|
||||
? "Nový záznam plánu"
|
||||
: mode.kind === "edit-range"
|
||||
? "Upravit rozsah"
|
||||
: "Upravit přepsání dne";
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode.kind === "create") {
|
||||
const userId = mode.userId;
|
||||
await onSaveEntry({
|
||||
user_id: userId,
|
||||
date_from: dateFrom,
|
||||
date_to: isRange ? dateTo : dateFrom,
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else if (mode.kind === "edit-range") {
|
||||
await onUpdateEntry(mode.entryId, {
|
||||
date_from: dateFrom,
|
||||
date_to: isRange ? dateTo : dateFrom,
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else {
|
||||
await onUpdateOverride(mode.overrideId, {
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode.kind === "edit-range") {
|
||||
await onDeleteEntry(mode.entryId);
|
||||
} else if (mode.kind === "edit-override") {
|
||||
await onDeleteOverride(mode.overrideId);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormModal
|
||||
isOpen
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
footerLeft={
|
||||
mode.kind !== "create" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Smazat
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FormField label="Datum od">
|
||||
<AdminDatePicker
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
disabled={isOverride}
|
||||
/>
|
||||
</FormField>
|
||||
{!isOverride && (
|
||||
<FormField label="Rozsah dnů">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<FormField label="Datum do">
|
||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">— bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{/* If the row's current category was retired/hidden, still show it
|
||||
(marked) so the select reflects reality instead of silently
|
||||
snapping to the first active option. The server only re-checks
|
||||
the category if it changes, so keeping it saves fine. */}
|
||||
{category && !activeCategories.some((c) => c.key === category) && (
|
||||
<option value={category}>
|
||||
{planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||||
)}{" "}
|
||||
(skrytá)
|
||||
</option>
|
||||
)}
|
||||
{activeCategories.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DayInRangeModal(
|
||||
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day-in-range" }> },
|
||||
) {
|
||||
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
||||
props;
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
// Two clear paths: edit the whole range (default, safe) or carve out
|
||||
// a one-day override (deviates from the plan). Close = no action.
|
||||
const choices = [
|
||||
{
|
||||
key: "edit-range",
|
||||
title: "Upravit celý rozsah",
|
||||
desc: `Změní projekt, kategorii nebo poznámku pro všechny dny v rozsahu ${mode.range.date_from} – ${mode.range.date_to}.`,
|
||||
tone: "primary" as const,
|
||||
onClick: () => onSwitchToEditRange(mode.entryId, mode.userId, mode.date),
|
||||
cta: "Upravit rozsah",
|
||||
},
|
||||
{
|
||||
key: "override-day",
|
||||
title: "Upravit pouze tento den",
|
||||
desc: `Vytvoří přepsání pro ${mode.range.date_from === mode.date ? "tento" : mode.date}; zbytek rozsahu zůstane beze změny.`,
|
||||
tone: "secondary" as const,
|
||||
onClick: async () => {
|
||||
await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
|
||||
onClose();
|
||||
},
|
||||
cta: "Vytvořit přepsání",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
hideFooter
|
||||
size="md"
|
||||
>
|
||||
<p className="plan-modal-intro">
|
||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||
<strong>
|
||||
{mode.range.date_from} – {mode.range.date_to}
|
||||
</strong>
|
||||
. Vyberte, co chcete udělat:
|
||||
</p>
|
||||
<ul className="plan-choices">
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.li
|
||||
key={c.key}
|
||||
className={`plan-choice plan-choice-${c.tone}`}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.22,
|
||||
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="plan-choice-body">
|
||||
<div className="plan-choice-title">{c.title}</div>
|
||||
<div className="plan-choice-desc">{c.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
c.tone === "primary"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewModal(props: Props) {
|
||||
const { mode, onClose, projects, categories } = props;
|
||||
if (mode.kind !== "view") return null;
|
||||
const c = mode.cell;
|
||||
// Prefer the server-embedded project label; fall back to the projects-list
|
||||
// lookup only for cells that predate the embed (stale cache).
|
||||
const fallback = c.project_id
|
||||
? (projects.find((p) => p.id === c.project_id) ?? null)
|
||||
: null;
|
||||
const projectLabel =
|
||||
cellProjectLabel(c) ??
|
||||
(fallback
|
||||
? fallback.project_number
|
||||
? `${fallback.project_number} — ${fallback.name}`
|
||||
: fallback.name
|
||||
: null);
|
||||
return (
|
||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||
<p>
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Kategorie:</strong>{" "}
|
||||
{planCategoryLabel(
|
||||
c.category,
|
||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</p>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<p>
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</p>
|
||||
)}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
260
src/admin/components/PlanGrid.tsx
Normal file
260
src/admin/components/PlanGrid.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
GridData,
|
||||
ResolvedCell,
|
||||
planCategoryLabel,
|
||||
categoryMap,
|
||||
PlanCategory,
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
|
||||
interface Props {
|
||||
data: GridData | undefined;
|
||||
canEdit: boolean;
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
// The (userId, date) of the most recent successful mutation. The
|
||||
// matching cell gets a one-shot highlight pulse. `nonce` is part of
|
||||
// the value so back-to-back mutations on the same cell re-trigger
|
||||
// the animation (React needs a new key to re-mount the class).
|
||||
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
||||
onCellClick: (
|
||||
userId: number,
|
||||
date: string,
|
||||
cell: ResolvedCell | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
||||
// Used in the date column "stamp" so the day name reads in full rather
|
||||
// than as a 2-letter abbreviation — easier to scan when the planner
|
||||
// spans several weeks.
|
||||
const CZECH_WEEKDAYS = [
|
||||
"Neděle",
|
||||
"Pondělí",
|
||||
"Úterý",
|
||||
"Středa",
|
||||
"Čtvrtek",
|
||||
"Pátek",
|
||||
"Sobota",
|
||||
];
|
||||
|
||||
function eachDay(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
const a = new Date(from + "T00:00:00.000Z");
|
||||
const b = new Date(to + "T00:00:00.000Z");
|
||||
for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
out.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function czechWeekday(dateStr: string): string {
|
||||
return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()];
|
||||
}
|
||||
|
||||
function isWeekend(dateStr: string): boolean {
|
||||
const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
// Today's local date as YYYY-MM-DD. Used to mark the current row and to
|
||||
// gate past-day editing. Uses local time (matches the project's date
|
||||
// conventions in CLAUDE.md and the server's `assertNotPastDate` guard in
|
||||
// src/services/plan.service.ts).
|
||||
function todayIso(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// True when the given YYYY-MM-DD cell date is strictly before today (local).
|
||||
// Used to gate the create / edit UI on past days — the server still enforces
|
||||
// the rule with a 403, but the client should never put the user in the
|
||||
// "I clicked, modal opened, error toast" state in the first place.
|
||||
function isPastDate(dateStr: string, today: string): boolean {
|
||||
return dateStr < today;
|
||||
}
|
||||
|
||||
// Split a full name into first / last for the column header.
|
||||
function splitName(full: string): { first: string; last: string } {
|
||||
const parts = full.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { first: parts[0], last: "" };
|
||||
return { first: parts[0], last: parts.slice(1).join(" ") };
|
||||
}
|
||||
|
||||
// Short role label for the column header sub-line.
|
||||
function shortRole(role: string | null): string {
|
||||
if (!role) return "";
|
||||
// Keep it brief — column header is tight
|
||||
if (/^admin$/i.test(role)) return "Admin";
|
||||
if (/^viewer$/i.test(role)) return "Viewer";
|
||||
return role.length > 10 ? role.slice(0, 9) + "…" : role;
|
||||
}
|
||||
|
||||
export default function PlanGrid({
|
||||
data,
|
||||
canEdit,
|
||||
projects,
|
||||
categories,
|
||||
pulseKey,
|
||||
onCellClick,
|
||||
}: Props) {
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
const days = eachDay(data.date_from, data.date_to);
|
||||
const users = data.users;
|
||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||
// mutation on the same cell re-triggers the animation (CSS animations
|
||||
// don't restart unless the keyframe applies to a fresh element/class
|
||||
// binding).
|
||||
const pulseAttr = pulseKey
|
||||
? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||
`table-layout: auto` happily blows past. The first column
|
||||
gets a fixed width via the col element so the date stamp
|
||||
doesn't get stretched to share space with person columns. */}
|
||||
<colgroup>
|
||||
<col className="plan-grid-date-col" />
|
||||
{users.map((u) => (
|
||||
<col key={u.id} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="plan-grid-date-col">Datum</th>
|
||||
{users.map((u) => {
|
||||
const { first, last } = splitName(u.full_name);
|
||||
return (
|
||||
<th key={u.id}>
|
||||
<span className="plan-person-head">
|
||||
<span className="plan-person-dot" aria-hidden />
|
||||
<span className="plan-person-name">
|
||||
<strong title={u.full_name}>
|
||||
{first}
|
||||
{last ? ` ${last}` : ""}
|
||||
</strong>
|
||||
{shortRole(u.role_name) && (
|
||||
<small>{shortRole(u.role_name)}</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((date) => {
|
||||
const dow = czechWeekday(date);
|
||||
const dayNum = date.slice(8, 10);
|
||||
const isToday = date === today;
|
||||
const trCls = [
|
||||
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||
isToday ? "is-today" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<tr key={date} className={trCls}>
|
||||
<td className="plan-grid-date-col">
|
||||
<span className="plan-date-stamp">
|
||||
<span className="plan-date-daynum">{dayNum}</span>
|
||||
<span className="plan-date-dow">{dow}</span>
|
||||
</span>
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cell = data.cells[u.id]?.[date] ?? null;
|
||||
const past = isPastDate(date, today);
|
||||
// Past-day cells are read-only in the UI: an empty past
|
||||
// cell is non-interactive (no create modal, no "+" hint),
|
||||
// a past cell with data opens in view mode only. The
|
||||
// server still enforces the past-date rule with a 403
|
||||
// (defense in depth), but the click path here never
|
||||
// reaches a create/edit submission for a past date.
|
||||
const isLocked = !canEdit || past;
|
||||
// `isPulsing` is true for the single (user, date) cell
|
||||
// that the most recent successful mutation touched.
|
||||
// CSS restarts the keyframe animation whenever the
|
||||
// `nonce` changes (we embed it in data-pulse on the
|
||||
// wrapper, see above), so back-to-back mutations on the
|
||||
// same cell re-trigger the pulse.
|
||||
const isPulsing =
|
||||
!!pulseKey &&
|
||||
pulseKey.userId === u.id &&
|
||||
pulseKey.date === date;
|
||||
let cls: string;
|
||||
if (cell) {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell";
|
||||
} else {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell plan-cell--empty";
|
||||
}
|
||||
if (isPulsing) cls += " plan-cell--pulse";
|
||||
return (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={
|
||||
cell
|
||||
? ({
|
||||
"--cat-color": catMap[cell.category]?.color,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onCellClick(u.id, date, cell)}
|
||||
aria-label={
|
||||
cell
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={cell}
|
||||
project={
|
||||
cell?.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === cell.project_id,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={
|
||||
cell ? planCategoryLabel(cell.category, catMap) : ""
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/admin/components/PlanRangeChips.tsx
Normal file
42
src/admin/components/PlanRangeChips.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ResolvedCell, cellProjectLabel } from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
|
||||
interface Props {
|
||||
cell: ResolvedCell | null;
|
||||
project: Project | null;
|
||||
readonly?: boolean;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
export default function PlanRangeChips({
|
||||
cell,
|
||||
project,
|
||||
readonly,
|
||||
categoryLabel,
|
||||
}: Props) {
|
||||
void readonly;
|
||||
if (!cell) return null;
|
||||
// Prefer the server-embedded project label (always present). Fall back to
|
||||
// the looked-up `project` prop only if the cell predates the embed (stale
|
||||
// cache).
|
||||
const fallbackLabel = project
|
||||
? project.project_number
|
||||
? `${project.project_number} — ${project.name}`
|
||||
: project.name
|
||||
: null;
|
||||
const projectLabel = cellProjectLabel(cell) ?? fallbackLabel;
|
||||
const projectTitle = projectLabel ?? undefined;
|
||||
return (
|
||||
<div className="plan-chip-block">
|
||||
<div className="plan-chip-line">
|
||||
<span className="plan-chip">{categoryLabel}</span>
|
||||
{projectLabel && (
|
||||
<span className="plan-chip-project" title={projectTitle}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cell.note && <div className="plan-cell-note">{cell.note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,26 @@ const menuSections: MenuSection[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/plan-work",
|
||||
label: "Plán prací",
|
||||
permission: ["attendance.manage", "attendance.record"],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
527
src/admin/hooks/usePlanWork.ts
Normal file
527
src/admin/hooks/usePlanWork.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useMutation,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import apiFetch from "../utils/api";
|
||||
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";
|
||||
|
||||
export type ViewMode = "week" | "month";
|
||||
export type ModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
||||
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
|
||||
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
|
||||
| { kind: "view"; userId: number; date: string };
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getUTCDay(); // 0 = Sun
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday-start
|
||||
const r = new Date(d);
|
||||
r.setUTCDate(d.getUTCDate() + diff);
|
||||
return r;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setUTCDate(d.getUTCDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function apiCall(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<unknown> {
|
||||
const res = await apiFetch(url, options);
|
||||
if (!res.ok) {
|
||||
let message = "Chyba serveru";
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.error === "string") message = body.error;
|
||||
} catch {
|
||||
// body wasn't JSON; use default
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsePlanWorkArgs {
|
||||
initialDate?: Date;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
const qc = useQueryClient();
|
||||
const [view, setView] = useState<ViewMode>("week");
|
||||
const [anchor, setAnchor] = useState<Date>(initialDate ?? new Date());
|
||||
const [filterActive, setFilterActive] = useState(true);
|
||||
const [modal, setModal] = useState<ModalMode>({ kind: "closed" });
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (view === "week") {
|
||||
const from = startOfWeek(anchor);
|
||||
const to = addDays(from, 6);
|
||||
return { from, to };
|
||||
}
|
||||
const from = new Date(
|
||||
Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1),
|
||||
);
|
||||
const to = new Date(
|
||||
Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0),
|
||||
);
|
||||
return { from, to };
|
||||
}, [view, anchor]);
|
||||
|
||||
const gridQuery = useQuery({
|
||||
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
||||
).then((r) => r.json().then((b: any) => b.data as GridData)),
|
||||
// keepPreviousData holds the old range's data visible while the new
|
||||
// range's fetch is in flight. Without this, `gridQuery.data` is
|
||||
// immediately undefined on key change and the grid flashes empty.
|
||||
// `isPlaceholderData` is true while the displayed data is stale.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: planKeys.all });
|
||||
// Plan mutations write audit-log rows, so they touch the audit domain
|
||||
// too. Invalidate the dashboard activity feed and the audit-log page so
|
||||
// they reflect the change without a manual F5 (prefix-matches all
|
||||
// ["audit-log", {...}] filter variants). The dashboard query is active
|
||||
// when visible and refetches immediately despite its staleTime.
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["audit-log"] });
|
||||
}, [qc]);
|
||||
|
||||
// --- Optimistic cache patches ---------------------------------------------
|
||||
// After a mutation succeeds, we patch the visible grid query's data
|
||||
// *before* the refetch resolves. This is what stops the full-grid
|
||||
// re-animation: the visible data updates in place (and the new cell
|
||||
// gets a one-shot pulse via PlanWork's lastMutated state), so the grid
|
||||
// never unmounts. The subsequent invalidate+refetch confirms the patch
|
||||
// from the server (which is the source of truth) and is a no-op if the
|
||||
// server's data matches our patch.
|
||||
//
|
||||
// If the mutation fails, the mutation's onError handler in PlanWork
|
||||
// rolls the cache back to the snapshot we took before the patch.
|
||||
|
||||
const currentGridKey = planKeys.grid(
|
||||
isoDate(range.from),
|
||||
isoDate(range.to),
|
||||
view,
|
||||
);
|
||||
|
||||
// Walk the days in [from, to] inclusive (YYYY-MM-DD strings). Used to
|
||||
// figure out which cell keys to patch for a range entry.
|
||||
function eachDay(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
const a = new Date(from + "T00:00:00.000Z");
|
||||
const b = new Date(to + "T00:00:00.000Z");
|
||||
for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
out.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Snapshot the currently-cached grid for a given key. Returns
|
||||
// `undefined` if there's nothing cached (we don't patch empty data).
|
||||
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
||||
return qc.getQueryData<GridData>(key as any);
|
||||
}
|
||||
|
||||
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
||||
// previous cells[userId] map so the caller can roll back on error.
|
||||
function patchCells(
|
||||
key: readonly unknown[],
|
||||
userId: number,
|
||||
dates: string[],
|
||||
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
|
||||
): Record<string, ResolvedCell | null> | null {
|
||||
const prev = snapshotGrid(key);
|
||||
if (!prev) return null;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const rolled: Record<string, ResolvedCell | null> = {};
|
||||
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
|
||||
for (const date of dates) {
|
||||
rolled[date] = userPrev[date] ?? null;
|
||||
userNext[date] = mutator(rolled[date]);
|
||||
}
|
||||
qc.setQueryData(key, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
return rolled;
|
||||
}
|
||||
|
||||
// Build a ResolvedCell for a freshly-created entry. The server returns
|
||||
// a row with the same fields, so we mirror that shape. We don't have
|
||||
// entryId/overrideId from the request body — but the createEntry
|
||||
// response *does* include id; we could plumb it through. For now we
|
||||
// leave entryId as a placeholder that the refetch will replace.
|
||||
// (PlanGrid only uses source/entryId/overrideId to decide edit flow;
|
||||
// a fresh cell from an entry is "entry" source — the refetch fills
|
||||
// the actual id within ~200ms.)
|
||||
function makeEntryCell(args: {
|
||||
userId: number;
|
||||
date: string;
|
||||
projectId: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
entryId: number | null;
|
||||
}): ResolvedCell {
|
||||
return {
|
||||
source: "entry",
|
||||
entryId: args.entryId,
|
||||
overrideId: null,
|
||||
user_id: args.userId,
|
||||
shift_date: args.date,
|
||||
project_id: args.projectId,
|
||||
// Optimistic placeholder — the invalidate+refetch fills the real
|
||||
// project label (resolveGrid embeds it) within ~200ms.
|
||||
project_number: null,
|
||||
project_name: null,
|
||||
category: args.category,
|
||||
note: args.note,
|
||||
rangeFrom: args.rangeFrom,
|
||||
rangeTo: args.rangeTo,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOverrideCell(args: {
|
||||
userId: number;
|
||||
date: string;
|
||||
projectId: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
overrideId: number | null;
|
||||
}): ResolvedCell {
|
||||
return {
|
||||
source: "override",
|
||||
entryId: null,
|
||||
overrideId: args.overrideId,
|
||||
user_id: args.userId,
|
||||
shift_date: args.date,
|
||||
project_id: args.projectId,
|
||||
// Optimistic placeholder — refetch fills the real project label.
|
||||
project_number: null,
|
||||
project_name: null,
|
||||
category: args.category,
|
||||
note: args.note,
|
||||
rangeFrom: null,
|
||||
rangeTo: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
const createEntry = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, body: any) => {
|
||||
// Patch the visible grid with the new entry. We use the response
|
||||
// id (server-assigned) for the new entry's entryId; the rest of
|
||||
// the ResolvedCell mirrors the request body.
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(currentGridKey, body.user_id, days, () =>
|
||||
makeEntryCell({
|
||||
userId: body.user_id,
|
||||
date: days[0],
|
||||
projectId: body.project_id ?? null,
|
||||
category: body.category,
|
||||
note: body.note,
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
);
|
||||
// Stash the rollback on the mutation object so PlanWork can call
|
||||
// it from onError.
|
||||
(createEntry as any)._rolled = rolled;
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateEntry = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// We don't know the new full range without the response, but we
|
||||
// do have the body's date_from/date_to. If those are present,
|
||||
// patch the new range. Old cells outside the new range are NOT
|
||||
// cleared here — they'll either still be valid (date_from/to
|
||||
// were partial updates) or the refetch will fix them.
|
||||
const { id, body } = vars;
|
||||
if (body.date_from && body.date_to) {
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
// Find the user that owns this entry in the current grid by
|
||||
// looking for any cell with entryId === id (we already know
|
||||
// the id from vars; it doesn't change across the update).
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
let ownerUserId: number | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const cell of Object.values(byDate)) {
|
||||
if (cell && cell.entryId === id) {
|
||||
ownerUserId = Number(uidStr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) {
|
||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
|
||||
makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (prev?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? prev?.category ?? "work",
|
||||
note: body.note ?? prev?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
);
|
||||
(updateEntry as any)._rolled = rolled;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteEntry = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// For delete we need to know the entry's user_id and full range.
|
||||
// Look it up from the current grid: find the user that has a cell
|
||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [, cell] of Object.entries(byDate)) {
|
||||
if (
|
||||
cell &&
|
||||
cell.entryId === vars.id &&
|
||||
cell.rangeFrom &&
|
||||
cell.rangeTo
|
||||
) {
|
||||
const days = eachDay(cell.rangeFrom, cell.rangeTo);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
() => null,
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const createOverride = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/overrides", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, vars: any) => {
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
vars.user_id,
|
||||
[vars.shift_date],
|
||||
() =>
|
||||
makeOverrideCell({
|
||||
userId: vars.user_id,
|
||||
date: vars.shift_date,
|
||||
projectId: vars.project_id ?? null,
|
||||
category: vars.category,
|
||||
note: vars.note,
|
||||
overrideId: id,
|
||||
}),
|
||||
);
|
||||
(createOverride as any)._rolled = rolled;
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateOverride = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// Find the user/date for this overrideId in the current grid, then
|
||||
// patch that single cell with the new values.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) =>
|
||||
makeOverrideCell({
|
||||
userId: Number(uidStr),
|
||||
date,
|
||||
projectId: vars.body.project_id ?? prev?.project_id ?? null,
|
||||
category: vars.body.category ?? prev?.category ?? "work",
|
||||
note: vars.body.note ?? prev?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
}),
|
||||
);
|
||||
(updateOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteOverride = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
() => null,
|
||||
);
|
||||
(deleteOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Roll back an optimistic patch on mutation error. Called from
|
||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
||||
// on it (see the `(createEntry as any)._rolled = …` writes above). Typed as
|
||||
// `unknown` + an explicit cast so callers can pass the mutation object
|
||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
||||
function rollbackMutation(mutation: unknown, userId: number) {
|
||||
const stash = mutation as {
|
||||
_rolled?: Record<string, ResolvedCell | null> | null;
|
||||
};
|
||||
if (!stash._rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cell] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cell;
|
||||
}
|
||||
qc.setQueryData(currentGridKey, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
stash._rolled = null;
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
setView,
|
||||
anchor,
|
||||
setAnchor,
|
||||
filterActive,
|
||||
setFilterActive,
|
||||
range,
|
||||
grid: gridQuery.data,
|
||||
gridLoading: gridQuery.isLoading,
|
||||
gridFetching: gridQuery.isFetching,
|
||||
gridIsPlaceholder: gridQuery.isPlaceholderData,
|
||||
modal,
|
||||
setModal,
|
||||
canEdit,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
invalidate,
|
||||
// Exposed so PlanWork can roll back an optimistic patch when a
|
||||
// mutation throws. Pass the failed mutation and the userId it
|
||||
// targeted.
|
||||
rollbackMutation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCell(
|
||||
grid: GridData | undefined,
|
||||
userId: number,
|
||||
date: string,
|
||||
): ResolvedCell | null {
|
||||
return grid?.cells?.[userId]?.[date] ?? null;
|
||||
}
|
||||
115
src/admin/lib/queries/plan.ts
Normal file
115
src/admin/lib/queries/plan.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface PlanUser {
|
||||
id: number;
|
||||
full_name: string;
|
||||
username: string;
|
||||
role_name: string | null;
|
||||
is_active: boolean;
|
||||
has_attendance_record: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedCell {
|
||||
source: "entry" | "override";
|
||||
entryId: number | null;
|
||||
overrideId: number | null;
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
project_id: number | null;
|
||||
/** Project label resolved server-side (see resolveGrid). Lets the UI show
|
||||
* the project without depending on the capped/permission-gated projects
|
||||
* list. Null when the cell has no project. */
|
||||
project_number: string | null;
|
||||
project_name: string | null;
|
||||
category: string;
|
||||
note: string;
|
||||
rangeFrom: string | null;
|
||||
rangeTo: string | null;
|
||||
}
|
||||
|
||||
export interface PlanCategory {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export const planCategoriesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["plan", "categories"],
|
||||
queryFn: () => jsonQuery<PlanCategory[]>("/api/admin/plan/categories"),
|
||||
});
|
||||
|
||||
/** Build a key → category lookup from the categories list. */
|
||||
export function categoryMap(
|
||||
categories: PlanCategory[] | undefined,
|
||||
): Record<string, PlanCategory> {
|
||||
const map: Record<string, PlanCategory> = {};
|
||||
for (const c of categories ?? []) map[c.key] = c;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Czech label for a plan category key, resolved from the loaded categories
|
||||
* map, falling back to the raw key. The `map` arg is optional so callers not
|
||||
* yet threading categories still compile (they show the raw key until wired). */
|
||||
export function planCategoryLabel(
|
||||
key: string,
|
||||
map?: Record<string, PlanCategory>,
|
||||
): string {
|
||||
return map?.[key]?.label ?? key;
|
||||
}
|
||||
|
||||
/** Build the display label for a cell's project, preferring the server-
|
||||
* embedded fields. Returns null when the cell has no project. */
|
||||
export function cellProjectLabel(cell: {
|
||||
project_number: string | null;
|
||||
project_name: string | null;
|
||||
}): string | null {
|
||||
if (!cell.project_number && !cell.project_name) return null;
|
||||
if (cell.project_number && cell.project_name) {
|
||||
return `${cell.project_number} — ${cell.project_name}`;
|
||||
}
|
||||
return cell.project_number ?? cell.project_name;
|
||||
}
|
||||
|
||||
export interface GridData {
|
||||
view: "week" | "month";
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
users: PlanUser[];
|
||||
cells: Record<number, Record<string, ResolvedCell | null>>;
|
||||
}
|
||||
|
||||
export const planKeys = {
|
||||
all: ["plan"] as const,
|
||||
grid: (dateFrom: string, dateTo: string, view: "week" | "month") =>
|
||||
["plan", "grid", { dateFrom, dateTo, view }] as const,
|
||||
entries: (userId: number | null, dateFrom: string, dateTo: string) =>
|
||||
["plan", "entries", { userId, dateFrom, dateTo }] as const,
|
||||
overrides: (userId: number | null, dateFrom: string, dateTo: string) =>
|
||||
["plan", "overrides", { userId, dateFrom, dateTo }] as const,
|
||||
users: () => ["plan", "users"] as const,
|
||||
};
|
||||
|
||||
export const gridQuery = (
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
view: "week" | "month",
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: planKeys.grid(dateFrom, dateTo, view),
|
||||
queryFn: () =>
|
||||
jsonQuery<GridData>(
|
||||
`/api/admin/plan/grid?date_from=${dateFrom}&date_to=${dateTo}&view=${view}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const usersQuery = () =>
|
||||
queryOptions({
|
||||
queryKey: planKeys.users(),
|
||||
queryFn: () => jsonQuery<PlanUser[]>("/api/admin/plan/users"),
|
||||
});
|
||||
@@ -21,6 +21,9 @@ interface UserTotalData {
|
||||
unpaid_hours: number;
|
||||
fund: number | null;
|
||||
worked_hours: number;
|
||||
// Raw worked hours incl. sub-day remainders (set by computeUserTotals in
|
||||
// useAttendanceAdmin); used for the Fond "used" calculation below.
|
||||
worked_hours_raw?: number;
|
||||
covered: number;
|
||||
missing: number;
|
||||
overtime: number;
|
||||
|
||||
@@ -60,6 +60,9 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
trips: "Jízda",
|
||||
vehicles: "Vozidlo",
|
||||
bank_account: "Bankovní účet",
|
||||
work_plan_entry: "Plán prací – záznam",
|
||||
work_plan_override: "Plán prací – přepsání dne",
|
||||
plan_category: "Plán prací – kategorie",
|
||||
};
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||
|
||||
717
src/admin/pages/PlanWork.tsx
Normal file
717
src/admin/pages/PlanWork.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import {
|
||||
usePlanWork,
|
||||
getCell,
|
||||
type ModalMode as HookModalMode,
|
||||
} from "../hooks/usePlanWork";
|
||||
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
||||
import PlanGrid from "../components/PlanGrid";
|
||||
import PlanCellModal from "../components/PlanCellModal";
|
||||
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||||
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
|
||||
import "../plan.css";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Leden",
|
||||
"Únor",
|
||||
"Březen",
|
||||
"Duben",
|
||||
"Květen",
|
||||
"Červen",
|
||||
"Červenec",
|
||||
"Srpen",
|
||||
"Září",
|
||||
"Říjen",
|
||||
"Listopad",
|
||||
"Prosinec",
|
||||
];
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setUTCDate(d.getUTCDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
function shiftAnchor(
|
||||
anchor: Date,
|
||||
view: "week" | "month",
|
||||
direction: 1 | -1,
|
||||
): Date {
|
||||
if (view === "week") {
|
||||
return addDays(anchor, direction * 7);
|
||||
}
|
||||
// Month view: jump a calendar month
|
||||
const d = new Date(anchor);
|
||||
d.setUTCMonth(d.getUTCMonth() + direction);
|
||||
return d;
|
||||
}
|
||||
|
||||
function isoWeekNumber(d: Date): number {
|
||||
// ISO-8601 week number
|
||||
const target = new Date(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
const dayNr = (target.getUTCDay() + 6) % 7;
|
||||
target.setUTCDate(target.getUTCDate() - dayNr + 3);
|
||||
const jan1 = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((target.getTime() - jan1.getTime()) / DAY_MS + 1) / 7);
|
||||
}
|
||||
|
||||
function formatRangeLabel(
|
||||
from: string,
|
||||
to: string,
|
||||
view: "week" | "month",
|
||||
): string {
|
||||
const fmt = (s: string) => {
|
||||
const [, mm, dd] = s.split("-");
|
||||
return `${Number(dd)}.${Number(mm)}.`;
|
||||
};
|
||||
if (view === "month") {
|
||||
const [y, m] = from.split("-");
|
||||
return `${MONTH_NAMES[Number(m) - 1]} ${y}`;
|
||||
}
|
||||
const fromDate = new Date(from + "T00:00:00.000Z");
|
||||
const week = isoWeekNumber(fromDate);
|
||||
return `Týden ${week} (${fmt(from)} – ${fmt(to)} ${fromDate.getUTCFullYear()})`;
|
||||
}
|
||||
|
||||
// Local-time YYYY-MM-DD for "now". Matches the server's
|
||||
// `assertNotPastDate` guard in src/services/plan.service.ts and the
|
||||
// `todayIso()` helper in PlanGrid — all three use the same local-time
|
||||
// getters so the past-date gate stays consistent across layers.
|
||||
function todayIsoLocal(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function isPastDate(dateStr: string, today: string): boolean {
|
||||
return dateStr < today;
|
||||
}
|
||||
|
||||
export default function PlanWork() {
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const canEdit = hasPermission("attendance.manage");
|
||||
|
||||
const {
|
||||
view,
|
||||
setView,
|
||||
setAnchor,
|
||||
range,
|
||||
grid,
|
||||
gridLoading,
|
||||
modal: hookModal,
|
||||
setModal: setHookModal,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
rollbackMutation,
|
||||
} = usePlanWork({ canEdit });
|
||||
|
||||
// Projects list — needed by the modal's project selector.
|
||||
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
|
||||
const projects: Project[] = (projectsData?.data as Project[]) ?? [];
|
||||
|
||||
const { data: categoriesData } = useQuery(planCategoriesOptions());
|
||||
const categories: PlanCategory[] = categoriesData ?? [];
|
||||
|
||||
const [catModalOpen, setCatModalOpen] = useState(false);
|
||||
|
||||
// `lastMutated` is the (userId, date) of the most recent successful
|
||||
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
|
||||
// gets a one-shot CSS pulse animation. We clear it after 700ms (the
|
||||
// animation duration + a small buffer) so subsequent mutations on
|
||||
// the same cell can re-trigger it.
|
||||
const [lastMutated, setLastMutated] = useState<{
|
||||
userId: number;
|
||||
date: string;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
const pulseTimer = useRef<number | null>(null);
|
||||
const firePulse = useCallback((userId: number, date: string) => {
|
||||
if (pulseTimer.current !== null) {
|
||||
window.clearTimeout(pulseTimer.current);
|
||||
}
|
||||
setLastMutated({ userId, date, nonce: Date.now() });
|
||||
pulseTimer.current = window.setTimeout(() => {
|
||||
setLastMutated(null);
|
||||
pulseTimer.current = null;
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pulseTimer.current !== null) {
|
||||
window.clearTimeout(pulseTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setAnchor((a) => shiftAnchor(a, view, -1));
|
||||
}, [setAnchor, view]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setAnchor((a) => shiftAnchor(a, view, 1));
|
||||
}, [setAnchor, view]);
|
||||
|
||||
const goToToday = useCallback(() => {
|
||||
setAnchor(new Date());
|
||||
}, [setAnchor]);
|
||||
|
||||
const setViewWithAnim = useCallback(
|
||||
(next: "week" | "month") => {
|
||||
setView(next);
|
||||
},
|
||||
[setView],
|
||||
);
|
||||
|
||||
// --- Modal helpers ---------------------------------------------------------
|
||||
// The hook stores a slim ModalMode (no `cell` / `range` payload). The
|
||||
// PlanCellModal component expects a richer mode with those fields. We keep
|
||||
// a separate `modalCell` state and merge it in at render time.
|
||||
|
||||
// The ResolvedCell currently being edited / viewed (filled on openCell).
|
||||
// Null when the modal is closed or in a "create" state with no source cell.
|
||||
const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);
|
||||
|
||||
const openCreate = useCallback(
|
||||
(userId: number, date: string) => {
|
||||
setModalCell(null);
|
||||
setHookModal({ kind: "create", userId, date });
|
||||
},
|
||||
[setHookModal],
|
||||
);
|
||||
|
||||
const openCell = useCallback(
|
||||
(userId: number, date: string) => {
|
||||
const cell = getCell(grid, userId, date);
|
||||
// Past-date guard: the server rejects create/edit on past dates with
|
||||
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
|
||||
// out of the "click → modal opens → error toast" state, route any
|
||||
// past-day click to view mode (or to no-op if there's no record).
|
||||
if (isPastDate(date, todayIsoLocal())) {
|
||||
if (!cell) {
|
||||
// Empty past cell — nothing to show or edit.
|
||||
return;
|
||||
}
|
||||
setModalCell(cell);
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
return;
|
||||
}
|
||||
if (!cell) {
|
||||
// Empty cell — open the create modal if the user can edit, otherwise
|
||||
// there's nothing to view.
|
||||
if (canEdit) openCreate(userId, date);
|
||||
return;
|
||||
}
|
||||
setModalCell(cell);
|
||||
if (cell.source === "entry" && cell.entryId !== null) {
|
||||
if (canEdit) {
|
||||
// A multi-day entry clicked on a single day → "day-in-range" so the
|
||||
// user can decide between editing the range or creating a one-day
|
||||
// override.
|
||||
if (
|
||||
cell.rangeFrom &&
|
||||
cell.rangeTo &&
|
||||
cell.rangeFrom !== cell.rangeTo
|
||||
) {
|
||||
setHookModal({
|
||||
kind: "day-in-range",
|
||||
entryId: cell.entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setHookModal({
|
||||
kind: "edit-range",
|
||||
entryId: cell.entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Read-only view
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
return;
|
||||
}
|
||||
if (cell.source === "override" && cell.overrideId !== null) {
|
||||
if (canEdit) {
|
||||
setHookModal({
|
||||
kind: "edit-override",
|
||||
overrideId: cell.overrideId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
} else {
|
||||
setHookModal({ kind: "view", userId, date });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// No source — treat as empty
|
||||
if (canEdit) openCreate(userId, date);
|
||||
},
|
||||
[grid, canEdit, openCreate, setHookModal],
|
||||
);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setHookModal({ kind: "closed" });
|
||||
setModalCell(null);
|
||||
}, [setHookModal]);
|
||||
|
||||
// Switch the modal from "day-in-range" to "edit-range" mode, keeping the
|
||||
// same cell context so the EditForm opens with the range's values.
|
||||
const switchToEditRange = useCallback(
|
||||
(entryId: number, userId: number, date: string) => {
|
||||
const cell = getCell(grid, userId, date);
|
||||
if (!cell) return;
|
||||
setModalCell(cell);
|
||||
setHookModal({
|
||||
kind: "edit-range",
|
||||
entryId,
|
||||
userId,
|
||||
date,
|
||||
});
|
||||
},
|
||||
[grid, setHookModal],
|
||||
);
|
||||
|
||||
// --- Mutation wrappers -----------------------------------------------------
|
||||
// The modal calls these with plain objects / ids. We resolve them to the
|
||||
// hook's mutateAsync calls, surface server errors as alerts, and (on
|
||||
// success) trigger the per-cell pulse. On failure we also roll back the
|
||||
// optimistic cache patch the hook already applied.
|
||||
|
||||
const saveEntry = useCallback(
|
||||
async (body: any) => {
|
||||
try {
|
||||
await createEntry.mutateAsync(body);
|
||||
// Pulse the first day of the new entry — for multi-day entries
|
||||
// the first day is what the modal was opened on (body.date_from).
|
||||
firePulse(body.user_id, body.date_from);
|
||||
alert.success("Záznam vytvořen");
|
||||
} catch (e) {
|
||||
rollbackMutation(createEntry, body.user_id);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[createEntry, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
const updateEntryFn = useCallback(
|
||||
async (id: number, body: any) => {
|
||||
// Find the user for this entry so we know which cell to pulse /
|
||||
// which cache to roll back. The hook's optimistic patch already
|
||||
// discovered this; we read it from the current grid.
|
||||
let userId: number | null = null;
|
||||
let firstDate: string | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.entryId === id) {
|
||||
userId = Number(uidStr);
|
||||
firstDate = body.date_from ?? date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (userId !== null) break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateEntry.mutateAsync({ id, body });
|
||||
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
|
||||
alert.success("Záznam aktualizován");
|
||||
} catch (e) {
|
||||
if (userId !== null) rollbackMutation(updateEntry, userId);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[updateEntry, grid, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
const deleteEntryFn = useCallback(
|
||||
async (id: number) => {
|
||||
// Find the user/date for the pulse + rollback.
|
||||
let userId: number | null = null;
|
||||
let firstDate: string | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.entryId === id) {
|
||||
userId = Number(uidStr);
|
||||
firstDate = cell.rangeFrom ?? date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (userId !== null) break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await deleteEntry.mutateAsync({ id });
|
||||
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
|
||||
alert.success("Záznam smazán");
|
||||
} catch (e) {
|
||||
if (userId !== null) rollbackMutation(deleteEntry, userId);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[deleteEntry, grid, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
const saveOverride = useCallback(
|
||||
async (body: any) => {
|
||||
try {
|
||||
await createOverride.mutateAsync(body);
|
||||
firePulse(body.user_id, body.shift_date);
|
||||
alert.success("Přepsání vytvořeno");
|
||||
} catch (e) {
|
||||
rollbackMutation(createOverride, body.user_id);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[createOverride, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
const updateOverrideFn = useCallback(
|
||||
async (id: number, body: any) => {
|
||||
// Find the user/date for the pulse + rollback.
|
||||
let userId: number | null = null;
|
||||
let date: string | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [d, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === id) {
|
||||
userId = Number(uidStr);
|
||||
date = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (userId !== null) break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateOverride.mutateAsync({ id, body });
|
||||
if (userId !== null && date !== null) firePulse(userId, date);
|
||||
alert.success("Přepsání aktualizováno");
|
||||
} catch (e) {
|
||||
if (userId !== null) rollbackMutation(updateOverride, userId);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[updateOverride, grid, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
const deleteOverrideFn = useCallback(
|
||||
async (id: number) => {
|
||||
let userId: number | null = null;
|
||||
let date: string | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [d, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === id) {
|
||||
userId = Number(uidStr);
|
||||
date = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (userId !== null) break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await deleteOverride.mutateAsync({ id });
|
||||
if (userId !== null && date !== null) firePulse(userId, date);
|
||||
alert.success("Přepsání smazáno");
|
||||
} catch (e) {
|
||||
if (userId !== null) rollbackMutation(deleteOverride, userId);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[deleteOverride, grid, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
// "Create override from range" — turn a multi-day entry into a one-day
|
||||
// override for the clicked date. We look up the cell's source entry and
|
||||
// create a new override mirroring its fields, locked to a single date.
|
||||
const createOverrideFromRange = useCallback(
|
||||
async (entryId: number, userId: number, date: string) => {
|
||||
const cell = getCell(grid, userId, date);
|
||||
if (!cell) {
|
||||
alert.error("Nepodařilo se najít zdrojový záznam");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createOverride.mutateAsync({
|
||||
user_id: userId,
|
||||
shift_date: date,
|
||||
project_id: cell.project_id,
|
||||
category: cell.category,
|
||||
note: cell.note,
|
||||
});
|
||||
firePulse(userId, date);
|
||||
alert.success("Přepsání dne vytvořeno");
|
||||
} catch (e) {
|
||||
rollbackMutation(createOverride, userId);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
throw e;
|
||||
}
|
||||
// Suppress unused-var warning for entryId — kept in the signature for
|
||||
// symmetry with the modal contract.
|
||||
void entryId;
|
||||
},
|
||||
[grid, createOverride, alert, firePulse, rollbackMutation],
|
||||
);
|
||||
|
||||
// --- Modal key for remount-on-mode-change ----------------------------------
|
||||
// PlanCellModal mounts internal state from `mode` once. Re-keying the
|
||||
// component whenever the mode changes (or the modal closes) forces a clean
|
||||
// remount so the new mode's initial state is computed freshly.
|
||||
const modalKey = useMemo(() => {
|
||||
if (hookModal.kind === "closed") return "closed";
|
||||
const m = hookModal as Exclude<HookModalMode, { kind: "closed" }>;
|
||||
return `${m.kind}-${m.userId}-${m.date}`;
|
||||
}, [hookModal]);
|
||||
|
||||
// Convert hook's ModalMode to PlanCellModalMode. The hook stores slim
|
||||
// variants (no `cell` / `range` payload). The modal component expects rich
|
||||
// variants with those fields; we fill them from `modalCell` (captured at
|
||||
// open-time) so the form keeps the right initial values even if the grid
|
||||
// is refetched while the modal is open.
|
||||
const modalMode: PlanCellModalMode = useMemo(() => {
|
||||
if (hookModal.kind === "closed") return { kind: "closed" };
|
||||
if (hookModal.kind === "create") return hookModal;
|
||||
if (hookModal.kind === "view") {
|
||||
if (!modalCell) return { kind: "closed" };
|
||||
return {
|
||||
kind: "view",
|
||||
userId: hookModal.userId,
|
||||
date: hookModal.date,
|
||||
cell: modalCell,
|
||||
};
|
||||
}
|
||||
if (hookModal.kind === "edit-override") {
|
||||
if (!modalCell) return { kind: "closed" };
|
||||
return {
|
||||
kind: "edit-override",
|
||||
overrideId: hookModal.overrideId,
|
||||
userId: hookModal.userId,
|
||||
date: hookModal.date,
|
||||
cell: modalCell,
|
||||
};
|
||||
}
|
||||
if (hookModal.kind === "edit-range" || hookModal.kind === "day-in-range") {
|
||||
if (!modalCell) return { kind: "closed" };
|
||||
return {
|
||||
...hookModal,
|
||||
range: {
|
||||
date_from: modalCell.rangeFrom ?? hookModal.date,
|
||||
date_to: modalCell.rangeTo ?? hookModal.date,
|
||||
project_id: modalCell.project_id,
|
||||
category: modalCell.category,
|
||||
note: modalCell.note,
|
||||
},
|
||||
} as PlanCellModalMode;
|
||||
}
|
||||
return { kind: "closed" };
|
||||
}, [hookModal, modalCell]);
|
||||
|
||||
if (!canEdit && !hasPermission("attendance.record")) {
|
||||
// Hard block for users who have neither manage nor record permission.
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plan-work-page">
|
||||
<motion.h1
|
||||
className="admin-page-title"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
Plán prací
|
||||
</motion.h1>
|
||||
|
||||
{!canEdit && (
|
||||
<motion.div
|
||||
className="plan-banner"
|
||||
role="status"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<span className="plan-banner-icon" aria-hidden>
|
||||
i
|
||||
</span>
|
||||
<span>
|
||||
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
||||
jsou dostupné oprávněným uživatelům.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="plan-toolbar"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={goToToday}
|
||||
>
|
||||
Dnes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={goPrev}
|
||||
aria-label="Předchozí"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={goNext}
|
||||
aria-label="Další"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<span className="plan-range-label-slot">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
className="plan-range-label"
|
||||
key={`${isoDate(range.from)}-${view}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<div
|
||||
className="plan-view-toggle"
|
||||
role="group"
|
||||
aria-label="Měřítko zobrazení"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
view === "week"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={() => setViewWithAnim("week")}
|
||||
>
|
||||
Týden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
view === "month"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={() => setViewWithAnim("month")}
|
||||
>
|
||||
Měsíc
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => setCatModalOpen(true)}
|
||||
>
|
||||
Správa kategorií
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{gridLoading && (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Grid entrance: same pattern as the h1 / banner / toolbar above
|
||||
(opacity + 12px translateY, 250ms ease-out). The stagger is
|
||||
h1 (0s) → banner (0.06s) → toolbar (0.12s) → grid (0.18s), so
|
||||
the page assembles top-to-bottom in a single cascade.
|
||||
|
||||
The motion.div mounts ONCE when `grid` first becomes defined
|
||||
and stays mounted across all range changes (keepPreviousData
|
||||
keeps `grid` defined even while a new fetch is in flight, so
|
||||
the condition never drops to null). This prevents the entrance
|
||||
animation from re-firing on every week/month switch.
|
||||
|
||||
Range changes are instant — keepPreviousData holds the old data
|
||||
visible until the new data arrives, so there's no flash. Mutations
|
||||
patch the cache in place and pulse a single cell via pulseKey.
|
||||
*/}
|
||||
{grid ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
|
||||
>
|
||||
<PlanGrid
|
||||
data={grid}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
canEdit={canEdit}
|
||||
pulseKey={lastMutated}
|
||||
onCellClick={openCell}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<PlanCellModal
|
||||
key={modalKey}
|
||||
open={hookModal.kind !== "closed"}
|
||||
mode={modalMode}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
onClose={closeModal}
|
||||
onSaveEntry={saveEntry}
|
||||
onUpdateEntry={updateEntryFn}
|
||||
onDeleteEntry={deleteEntryFn}
|
||||
onSaveOverride={saveOverride}
|
||||
onUpdateOverride={updateOverrideFn}
|
||||
onDeleteOverride={deleteOverrideFn}
|
||||
onCreateOverrideFromRange={createOverrideFromRange}
|
||||
onSwitchToEditRange={switchToEditRange}
|
||||
/>
|
||||
<PlanCategoriesModal
|
||||
isOpen={catModalOpen}
|
||||
onClose={() => setCatModalOpen(false)}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1024
src/admin/plan.css
Normal file
1024
src/admin/plan.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,9 @@ export const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
trips: "Jízda",
|
||||
vehicles: "Vozidlo",
|
||||
bank_account: "Bankovní účet",
|
||||
work_plan_entry: "Plán prací – záznam",
|
||||
work_plan_override: "Plán prací – přepsání dne",
|
||||
plan_category: "Plán prací – kategorie",
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<string, string> = {
|
||||
|
||||
404
src/routes/admin/plan.ts
Normal file
404
src/routes/admin/plan.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import {
|
||||
requireAuth,
|
||||
requirePermission,
|
||||
requireAnyPermission,
|
||||
} from "../../middleware/auth";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import {
|
||||
CreatePlanEntrySchema,
|
||||
UpdatePlanEntrySchema,
|
||||
CreatePlanOverrideSchema,
|
||||
UpdatePlanOverrideSchema,
|
||||
PlanRangeQuerySchema,
|
||||
PlanGridQuerySchema,
|
||||
} from "../../schemas/plan.schema";
|
||||
import {
|
||||
resolveGrid,
|
||||
listPlanUsers,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
listEntries,
|
||||
listOverrides,
|
||||
} from "../../services/plan.service";
|
||||
import {
|
||||
CreatePlanCategorySchema,
|
||||
UpdatePlanCategorySchema,
|
||||
} from "../../schemas/planCategory.schema";
|
||||
import {
|
||||
listPlanCategories,
|
||||
createPlanCategory,
|
||||
updatePlanCategory,
|
||||
deletePlanCategory,
|
||||
} from "../../services/planCategory.service";
|
||||
|
||||
const MAX_RANGE_DAYS = 92;
|
||||
|
||||
function isAdminLike(authData: any): boolean {
|
||||
return authData?.role === "admin";
|
||||
}
|
||||
|
||||
function forceFromQuery(query: unknown): boolean {
|
||||
if (!query || typeof query !== "object") return false;
|
||||
return (query as { force?: unknown }).force === "1";
|
||||
}
|
||||
|
||||
function daysBetween(from: string, to: string): number {
|
||||
const a = new Date(from + "T00:00:00.000Z").getTime();
|
||||
const b = new Date(to + "T00:00:00.000Z").getTime();
|
||||
return Math.round((b - a) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export default async function planRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// --- GET /plan/users ---
|
||||
app.get(
|
||||
"/users",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"attendance.manage",
|
||||
"attendance.record",
|
||||
),
|
||||
},
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const users = await listPlanUsers();
|
||||
return success(reply, users);
|
||||
},
|
||||
);
|
||||
|
||||
// --- GET /plan/grid ---
|
||||
app.get(
|
||||
"/grid",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"attendance.manage",
|
||||
"attendance.record",
|
||||
),
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = PlanGridQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return error(reply, parsed.error.issues[0].message, 400);
|
||||
}
|
||||
const { date_from, date_to, view } = parsed.data;
|
||||
if (daysBetween(date_from, date_to) > MAX_RANGE_DAYS) {
|
||||
return error(reply, "Maximální rozsah je 92 dní", 400);
|
||||
}
|
||||
const users = await listPlanUsers();
|
||||
const cells = await resolveGrid(
|
||||
users.map((u) => u.id),
|
||||
date_from,
|
||||
date_to,
|
||||
);
|
||||
return success(reply, { view, date_from, date_to, users, cells });
|
||||
},
|
||||
);
|
||||
|
||||
// --- GET /plan/entries ---
|
||||
app.get(
|
||||
"/entries",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"attendance.manage",
|
||||
"attendance.record",
|
||||
),
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = PlanRangeQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success)
|
||||
return error(reply, parsed.error.issues[0].message, 400);
|
||||
const rows = await listEntries(
|
||||
parsed.data,
|
||||
request.authData!.userId,
|
||||
isAdminLike(request.authData),
|
||||
);
|
||||
return success(reply, rows);
|
||||
},
|
||||
);
|
||||
|
||||
// --- GET /plan/overrides ---
|
||||
app.get(
|
||||
"/overrides",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"attendance.manage",
|
||||
"attendance.record",
|
||||
),
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = PlanRangeQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success)
|
||||
return error(reply, parsed.error.issues[0].message, 400);
|
||||
const rows = await listOverrides(
|
||||
parsed.data,
|
||||
request.authData!.userId,
|
||||
isAdminLike(request.authData),
|
||||
);
|
||||
return success(reply, rows);
|
||||
},
|
||||
);
|
||||
|
||||
// --- GET /plan/categories (any planner can read; needed for display) ---
|
||||
app.get(
|
||||
"/categories",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"attendance.manage",
|
||||
"attendance.record",
|
||||
),
|
||||
},
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const cats = await listPlanCategories(true);
|
||||
return success(reply, cats);
|
||||
},
|
||||
);
|
||||
|
||||
// --- POST /plan/categories ---
|
||||
app.post(
|
||||
"/categories",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(CreatePlanCategorySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const result = await createPlanCategory(body.data!);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "plan_category",
|
||||
entityId: result.data.id,
|
||||
newValues: result.data,
|
||||
description: `Kategorie: ${result.data.label}`,
|
||||
});
|
||||
return success(reply, result.data, 201, "Kategorie vytvořena");
|
||||
},
|
||||
);
|
||||
|
||||
// --- PATCH /plan/categories/:id ---
|
||||
app.patch(
|
||||
"/categories/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const body = parseBody(UpdatePlanCategorySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const result = await updatePlanCategory(id, body.data!);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "plan_category",
|
||||
entityId: id,
|
||||
newValues: result.data,
|
||||
description: `Kategorie: ${result.data.label}`,
|
||||
});
|
||||
return success(reply, result.data, 200, "Kategorie aktualizována");
|
||||
},
|
||||
);
|
||||
|
||||
// --- DELETE /plan/categories/:id (hard delete; blocked if in use) ---
|
||||
app.delete(
|
||||
"/categories/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const result = await deletePlanCategory(id);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "plan_category",
|
||||
entityId: id,
|
||||
description: "Kategorie smazána",
|
||||
});
|
||||
return success(reply, { ok: true }, 200, "Kategorie smazána");
|
||||
},
|
||||
);
|
||||
|
||||
// --- POST /plan/entries ---
|
||||
app.post(
|
||||
"/entries",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(CreatePlanEntrySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await createEntry(
|
||||
body.data!,
|
||||
request.authData!.userId,
|
||||
force,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "work_plan_entry",
|
||||
entityId: (result.data as any).id,
|
||||
newValues: result.data,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, result.data, 201, "Plán vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
// --- PATCH /plan/entries/:id ---
|
||||
app.patch(
|
||||
"/entries/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const body = parseBody(UpdatePlanEntrySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await updateEntry(
|
||||
id,
|
||||
body.data!,
|
||||
request.authData!.userId,
|
||||
force,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "work_plan_entry",
|
||||
entityId: id,
|
||||
oldValues: (result.oldData as any) ?? undefined,
|
||||
newValues: result.data as Record<string, unknown>,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, result.data, 200, "Plán aktualizován");
|
||||
},
|
||||
);
|
||||
|
||||
// --- DELETE /plan/entries/:id ---
|
||||
app.delete(
|
||||
"/entries/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await deleteEntry(id, request.authData!.userId, force);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "work_plan_entry",
|
||||
entityId: id,
|
||||
oldValues: (result.oldData as any) ?? undefined,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, { ok: true }, 200, "Plán smazán");
|
||||
},
|
||||
);
|
||||
|
||||
// --- POST /plan/overrides ---
|
||||
app.post(
|
||||
"/overrides",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(CreatePlanOverrideSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await createOverride(
|
||||
body.data!,
|
||||
request.authData!.userId,
|
||||
force,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
|
||||
// If the create replaced an existing active override, log the soft-delete first.
|
||||
if (result.replacedData) {
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "work_plan_override",
|
||||
entityId: (result.replacedData as any).id,
|
||||
oldValues: result.replacedData as Record<string, unknown>,
|
||||
description: result.replacedDescription,
|
||||
});
|
||||
}
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "work_plan_override",
|
||||
entityId: (result.data as any).id,
|
||||
newValues: result.data,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, result.data, 201, "Přepsání dne vytvořeno");
|
||||
},
|
||||
);
|
||||
|
||||
// --- PATCH /plan/overrides/:id ---
|
||||
app.patch(
|
||||
"/overrides/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const body = parseBody(UpdatePlanOverrideSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await updateOverride(
|
||||
id,
|
||||
body.data!,
|
||||
request.authData!.userId,
|
||||
force,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "work_plan_override",
|
||||
entityId: id,
|
||||
oldValues: (result.oldData as any) ?? undefined,
|
||||
newValues: result.data,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, result.data, 200, "Přepsání aktualizováno");
|
||||
},
|
||||
);
|
||||
|
||||
// --- DELETE /plan/overrides/:id ---
|
||||
app.delete(
|
||||
"/overrides/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
if (id === null) return;
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await deleteOverride(id, request.authData!.userId, force);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "work_plan_override",
|
||||
entityId: id,
|
||||
oldValues: (result.oldData as any) ?? undefined,
|
||||
description: result.description,
|
||||
});
|
||||
return success(reply, { ok: true }, 200, "Přepsání smazáno");
|
||||
},
|
||||
);
|
||||
}
|
||||
85
src/schemas/plan.schema.ts
Normal file
85
src/schemas/plan.schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const isoDate = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD");
|
||||
|
||||
const intFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.refine((n) => Number.isInteger(n) && n > 0, "Neplatné ID");
|
||||
|
||||
// Category is now a dynamic key (validated against plan_categories in the
|
||||
// service). Keep it a non-empty string here.
|
||||
const planCategoryEnum = z.string().trim().min(1, "Kategorie je povinná");
|
||||
|
||||
export const CreatePlanEntrySchema = z
|
||||
.object({
|
||||
user_id: intFromForm,
|
||||
date_from: isoDate,
|
||||
date_to: isoDate,
|
||||
project_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.nullish(),
|
||||
category: planCategoryEnum.default("work"),
|
||||
note: z.string().max(500, "Maximálně 500 znaků"),
|
||||
})
|
||||
.refine((v) => v.date_to >= v.date_from, {
|
||||
message: "Datum do musí být stejné nebo po datumu od",
|
||||
path: ["date_to"],
|
||||
});
|
||||
|
||||
export const UpdatePlanEntrySchema = z
|
||||
.object({
|
||||
date_from: isoDate.optional(),
|
||||
date_to: isoDate.optional(),
|
||||
project_id: z
|
||||
.union([z.number(), z.string(), z.null()])
|
||||
.transform((v) => (v === null ? null : Number(v)))
|
||||
.nullish(),
|
||||
category: planCategoryEnum.optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.date_from === undefined ||
|
||||
v.date_to === undefined ||
|
||||
v.date_to >= v.date_from,
|
||||
{
|
||||
message: "Datum do musí být stejné nebo po datumu od",
|
||||
path: ["date_to"],
|
||||
},
|
||||
);
|
||||
|
||||
export const CreatePlanOverrideSchema = z.object({
|
||||
user_id: intFromForm,
|
||||
shift_date: isoDate,
|
||||
project_id: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v))
|
||||
.nullish(),
|
||||
category: planCategoryEnum,
|
||||
note: z.string().max(500, "Maximálně 500 znaků"),
|
||||
});
|
||||
|
||||
export const UpdatePlanOverrideSchema = z.object({
|
||||
project_id: z
|
||||
.union([z.number(), z.string(), z.null()])
|
||||
.transform((v) => (v === null ? null : Number(v)))
|
||||
.nullish(),
|
||||
category: planCategoryEnum.optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const PlanRangeQuerySchema = z.object({
|
||||
user_id: intFromForm.optional(),
|
||||
date_from: isoDate,
|
||||
date_to: isoDate,
|
||||
});
|
||||
|
||||
export const PlanGridQuerySchema = z.object({
|
||||
date_from: isoDate,
|
||||
date_to: isoDate,
|
||||
view: z.enum(["week", "month"]).default("week"),
|
||||
});
|
||||
27
src/schemas/planCategory.schema.ts
Normal file
27
src/schemas/planCategory.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const hexColor = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, "Barva musí být ve formátu #RRGGBB");
|
||||
|
||||
export const CreatePlanCategorySchema = z.object({
|
||||
label: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Název je povinný")
|
||||
.max(100, "Maximálně 100 znaků"),
|
||||
color: hexColor,
|
||||
});
|
||||
|
||||
export const UpdatePlanCategorySchema = z
|
||||
.object({
|
||||
label: z.string().trim().min(1, "Název je povinný").max(100).optional(),
|
||||
color: hexColor.optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, {
|
||||
message: "Nic k aktualizaci",
|
||||
});
|
||||
|
||||
export type CreatePlanCategoryInput = z.infer<typeof CreatePlanCategorySchema>;
|
||||
export type UpdatePlanCategoryInput = z.infer<typeof UpdatePlanCategorySchema>;
|
||||
@@ -34,6 +34,7 @@ import offersPdfRoutes from "./routes/admin/offers-pdf";
|
||||
import ordersPdfRoutes from "./routes/admin/orders-pdf";
|
||||
import projectFilesRoutes from "./routes/admin/project-files";
|
||||
import warehouseRoutes from "./routes/admin/warehouse";
|
||||
import planRoutes from "./routes/admin/plan";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -150,6 +151,7 @@ async function start() {
|
||||
prefix: "/api/admin/project-files",
|
||||
});
|
||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||
|
||||
// --- Frontend: Vite dev middleware (dev only) ---
|
||||
if (!config.isProduction) {
|
||||
|
||||
@@ -351,7 +351,17 @@ export async function verifyAccessToken(
|
||||
}) as unknown as JwtPayload;
|
||||
return loadAuthData(payload.sub);
|
||||
} catch (err) {
|
||||
console.error("JWT verification error:", err);
|
||||
// An expired or otherwise invalid access token is an EXPECTED condition:
|
||||
// access tokens live ~15 min, and the client silently refreshes + retries
|
||||
// on the resulting 401 (see src/admin/utils/api.ts). Logging every such
|
||||
// case as an error — with a stack trace — floods the logs (roughly every
|
||||
// 15 min per active user) and buries genuine errors. `JsonWebTokenError`
|
||||
// is the base class of `TokenExpiredError`/`NotBeforeError`, so this skips
|
||||
// all normal token-validation failures while still surfacing anything
|
||||
// unexpected (e.g. a DB failure in loadAuthData).
|
||||
if (!(err instanceof jwt.JsonWebTokenError)) {
|
||||
console.error("Unexpected error verifying access token:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
811
src/services/plan.service.ts
Normal file
811
src/services/plan.service.ts
Normal file
@@ -0,0 +1,811 @@
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
buildPlanAuditDescription,
|
||||
planCategoryLabel as fallbackCategoryLabel,
|
||||
} from "../utils/planAuditDescription";
|
||||
|
||||
/**
|
||||
* Resolve display names for an audit description: the employee's full name
|
||||
* and the project name (or null when there is no project). Used so audit-log
|
||||
* rows show "Jan Novák · … · Projekt ABC" instead of bare numeric ids.
|
||||
* Falls back to "#id" placeholders if a row was concurrently removed.
|
||||
*/
|
||||
async function resolvePlanLabels(
|
||||
userId: number,
|
||||
projectId: number | null,
|
||||
): Promise<{ userName: string; projectName: string | null }> {
|
||||
const [user, project] = await Promise.all([
|
||||
prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
select: { first_name: true, last_name: true, username: true },
|
||||
}),
|
||||
projectId
|
||||
? prisma.projects.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { name: true, project_number: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const userName = user
|
||||
? `${user.first_name} ${user.last_name}`.trim() || user.username
|
||||
: `uživatel #${userId}`;
|
||||
|
||||
const projectName = project
|
||||
? project.name?.trim() || project.project_number || `projekt #${projectId}`
|
||||
: null;
|
||||
|
||||
return { userName, projectName };
|
||||
}
|
||||
|
||||
/** Resolve a category key to its Czech label from the DB, falling back to
|
||||
* the built-in map then the raw key. */
|
||||
async function resolveCategoryLabel(key: string): Promise<string> {
|
||||
const cat = await prisma.plan_categories.findUnique({
|
||||
where: { key },
|
||||
select: { label: true },
|
||||
});
|
||||
return cat?.label ?? fallbackCategoryLabel(key);
|
||||
}
|
||||
|
||||
/** Returns an error result if the category key is not an active category. */
|
||||
async function assertActiveCategory(
|
||||
key: string,
|
||||
): Promise<{ error: string; status: number } | null> {
|
||||
const cat = await prisma.plan_categories.findFirst({
|
||||
where: { key, is_active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this
|
||||
* file reads date-only columns (UTC slice), so it is timezone-stable. */
|
||||
function isoDay(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Prisma `select` for the joined project, plus a mapper to the cell's
|
||||
* project_number/project_name fields. Used so the grid carries the project
|
||||
* label without a second client-side lookup. */
|
||||
const projectSelect = { select: { project_number: true, name: true } } as const;
|
||||
|
||||
function projectFields(
|
||||
p: { project_number: string | null; name: string | null } | null | undefined,
|
||||
): { project_number: string | null; project_name: string | null } {
|
||||
return {
|
||||
project_number: p?.project_number ?? null,
|
||||
project_name: p?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved plan cell for a single (user, date). Either from a range entry
|
||||
* (work_plan_entries) or from a single-day override (work_plan_overrides).
|
||||
*/
|
||||
export interface ResolvedCell {
|
||||
/** Where the cell came from. */
|
||||
source: "entry" | "override";
|
||||
/** ID of the work_plan_entries row, or null when source is "override". */
|
||||
entryId: number | null;
|
||||
/** ID of the work_plan_overrides row, or null when source is "entry". */
|
||||
overrideId: number | null;
|
||||
user_id: number;
|
||||
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
|
||||
shift_date: string;
|
||||
project_id: number | null;
|
||||
/** Project number/name resolved server-side from the projects relation,
|
||||
* so the UI never depends on a separately-fetched (and capped/permission-
|
||||
* gated) projects list to display the project. Null when no project. */
|
||||
project_number: string | null;
|
||||
project_name: string | null;
|
||||
category: string;
|
||||
note: string;
|
||||
/** Original entry range, or null when source is "override". */
|
||||
rangeFrom: string | null;
|
||||
rangeTo: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective plan cell for (userId, dateStr).
|
||||
*
|
||||
* Precedence: override > entry > null.
|
||||
* Soft-deleted rows are ignored (is_deleted = false filter).
|
||||
* If multiple entries cover the same day, the latest by created_at wins
|
||||
* and a warning is logged.
|
||||
*
|
||||
* dateStr must be a YYYY-MM-DD string. The function builds a JS Date
|
||||
* from it and Prisma handles timezone conversion against the MySQL
|
||||
* @db.Date columns.
|
||||
*/
|
||||
export async function resolveCell(
|
||||
userId: number,
|
||||
dateStr: string,
|
||||
): Promise<ResolvedCell | null> {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
// 1. Single-day override for this exact date
|
||||
const override = await prisma.work_plan_overrides.findFirst({
|
||||
where: { user_id: userId, shift_date: date, is_deleted: false },
|
||||
include: { projects: projectSelect },
|
||||
});
|
||||
if (override) {
|
||||
return {
|
||||
source: "override",
|
||||
entryId: null,
|
||||
overrideId: override.id,
|
||||
user_id: override.user_id,
|
||||
shift_date: dateStr,
|
||||
project_id: override.project_id,
|
||||
...projectFields(override.projects),
|
||||
category: override.category,
|
||||
note: override.note,
|
||||
rangeFrom: null,
|
||||
rangeTo: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Range entry that covers the day
|
||||
const entries = await prisma.work_plan_entries.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
date_from: { lte: date },
|
||||
date_to: { gte: date },
|
||||
is_deleted: false,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: { projects: projectSelect },
|
||||
});
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
if (entries.length > 1) {
|
||||
// Multiple entries overlap. Use the latest. A persistent audit-log
|
||||
// entry would be ideal here, but logAudit() currently requires a
|
||||
// FastifyRequest — services have no request. Use console.warn as a
|
||||
// stand-in until a service-context audit logger is introduced.
|
||||
console.warn(
|
||||
`[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`,
|
||||
);
|
||||
}
|
||||
|
||||
const entry = entries[0];
|
||||
return {
|
||||
source: "entry",
|
||||
entryId: entry.id,
|
||||
overrideId: null,
|
||||
user_id: entry.user_id,
|
||||
shift_date: dateStr,
|
||||
project_id: entry.project_id,
|
||||
...projectFields(entry.projects),
|
||||
category: entry.category,
|
||||
note: entry.note,
|
||||
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
||||
rangeTo: entry.date_to.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective plan cell for every (userId, date) in the
|
||||
* given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null.
|
||||
*
|
||||
* Implementation: load all entries and overrides for the range in two
|
||||
* queries, then iterate the dates and resolve each cell in O(1) per
|
||||
* (user, date). This avoids N+1 calls to resolveCell.
|
||||
*/
|
||||
export async function resolveGrid(
|
||||
userIds: number[],
|
||||
dateFromStr: string,
|
||||
dateToStr: string,
|
||||
): Promise<Record<number, Record<string, ResolvedCell | null>>> {
|
||||
const dateFrom = new Date(dateFromStr);
|
||||
const dateTo = new Date(dateToStr);
|
||||
|
||||
const [entries, overrides] = await Promise.all([
|
||||
prisma.work_plan_entries.findMany({
|
||||
where: {
|
||||
user_id: { in: userIds },
|
||||
is_deleted: false,
|
||||
date_from: { lte: dateTo },
|
||||
date_to: { gte: dateFrom },
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: { projects: projectSelect },
|
||||
}),
|
||||
prisma.work_plan_overrides.findMany({
|
||||
where: {
|
||||
user_id: { in: userIds },
|
||||
is_deleted: false,
|
||||
shift_date: { gte: dateFrom, lte: dateTo },
|
||||
},
|
||||
include: { projects: projectSelect },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Build a date list (inclusive)
|
||||
const dates: string[] = [];
|
||||
for (
|
||||
let d = new Date(dateFrom);
|
||||
d <= dateTo;
|
||||
d.setUTCDate(d.getUTCDate() + 1)
|
||||
) {
|
||||
dates.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
const result: Record<number, Record<string, ResolvedCell | null>> = {};
|
||||
for (const uid of userIds) {
|
||||
result[uid] = {};
|
||||
for (const dateStr of dates) {
|
||||
// Override first
|
||||
const override = overrides.find(
|
||||
(o) =>
|
||||
o.user_id === uid &&
|
||||
o.shift_date.toISOString().slice(0, 10) === dateStr,
|
||||
);
|
||||
if (override) {
|
||||
result[uid][dateStr] = {
|
||||
source: "override",
|
||||
entryId: null,
|
||||
overrideId: override.id,
|
||||
user_id: override.user_id,
|
||||
shift_date: dateStr,
|
||||
project_id: override.project_id,
|
||||
...projectFields(override.projects),
|
||||
category: override.category,
|
||||
note: override.note,
|
||||
rangeFrom: null,
|
||||
rangeTo: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// First (latest by created_at) matching entry
|
||||
const entry = entries.find(
|
||||
(e) =>
|
||||
e.user_id === uid &&
|
||||
e.date_from <= new Date(dateStr) &&
|
||||
e.date_to >= new Date(dateStr),
|
||||
);
|
||||
if (entry) {
|
||||
result[uid][dateStr] = {
|
||||
source: "entry",
|
||||
entryId: entry.id,
|
||||
overrideId: null,
|
||||
user_id: entry.user_id,
|
||||
shift_date: dateStr,
|
||||
project_id: entry.project_id,
|
||||
...projectFields(entry.projects),
|
||||
category: entry.category,
|
||||
note: entry.note,
|
||||
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
||||
rangeTo: entry.date_to.toISOString().slice(0, 10),
|
||||
};
|
||||
} else {
|
||||
result[uid][dateStr] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface PlanUser {
|
||||
id: number;
|
||||
full_name: string;
|
||||
username: string;
|
||||
role_name: string | null;
|
||||
is_active: boolean;
|
||||
has_attendance_record: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return users that should appear as columns in the plan grid:
|
||||
* everyone with the `attendance.record` permission, ordered by
|
||||
* role/team name then full_name. Admins are included by default.
|
||||
*
|
||||
* The `users` model uses the relation field name `roles` (NOT `role`)
|
||||
* for the foreign key to `roles.id`. The roles-to-permissions join
|
||||
* table is `role_permissions`, which has a relation `permissions`.
|
||||
*/
|
||||
export async function listPlanUsers(): Promise<PlanUser[]> {
|
||||
const rows = await prisma.users.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
roles: {
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "attendance.record" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ roles: { name: "asc" } },
|
||||
{ last_name: "asc" },
|
||||
{ first_name: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
return rows.map((u) => ({
|
||||
id: u.id,
|
||||
full_name: `${u.first_name} ${u.last_name}`.trim(),
|
||||
username: u.username,
|
||||
role_name: u.roles?.name ?? null,
|
||||
is_active: u.is_active ?? true,
|
||||
has_attendance_record: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper: returns { error, status } if `dateStr` is in the past
|
||||
* and `force` is not set. Returns null if the date is editable.
|
||||
*
|
||||
* The comparison uses local Czech time (the project's TZ setting). The
|
||||
* previous implementation used `toISOString().slice(0, 10)`, which is
|
||||
* UTC — that could be off-by-one near day boundaries. The fix mirrors
|
||||
* the `Date.prototype.toJSON` override in `src/config/env.ts` which
|
||||
* also uses local-time getters, so the past-date gate is consistent
|
||||
* with how dates are formatted throughout the rest of the codebase.
|
||||
*/
|
||||
export function assertNotPastDate(
|
||||
dateStr: string,
|
||||
force: boolean,
|
||||
): { error: string; status: number } | null {
|
||||
if (force) return null;
|
||||
|
||||
const d = new Date();
|
||||
const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
if (dateStr < todayStr) {
|
||||
return {
|
||||
error:
|
||||
"Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Standard service result envelope. On success returns `{ data, oldData }`
|
||||
* where `oldData` is null for creates and the pre-update snapshot for
|
||||
* updates / deletes (so the route handler can call `logAudit` with it).
|
||||
*
|
||||
* The optional `replacedData` field is set only by `createOverride` when
|
||||
* an existing active override for the same (user, date) was soft-deleted
|
||||
* before creating the new one. The route handler uses it to emit a
|
||||
* second audit-log entry for the deletion of the replaced row.
|
||||
*
|
||||
* On failure returns `{ error, status }`.
|
||||
*/
|
||||
export type Result<T> =
|
||||
| {
|
||||
data: T;
|
||||
oldData: unknown | null;
|
||||
replacedData?: unknown | null;
|
||||
/** Human-readable Czech subject for the audit-log row. */
|
||||
description?: string;
|
||||
/** Audit description for the `replacedData` soft-delete row, if any. */
|
||||
replacedDescription?: string;
|
||||
}
|
||||
| { error: string; status: number };
|
||||
|
||||
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
|
||||
function toDateOnly(dateStr: string): Date {
|
||||
return new Date(dateStr + "T00:00:00.000Z");
|
||||
}
|
||||
|
||||
/** Create a work_plan_entries row. Past dates require force=true. */
|
||||
export async function createEntry(
|
||||
input: {
|
||||
user_id: number;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
},
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<
|
||||
Result<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
date_from: Date;
|
||||
date_to: Date;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
created_by: number;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
is_deleted: boolean | null;
|
||||
}>
|
||||
> {
|
||||
// Range check
|
||||
if (input.date_to < input.date_from) {
|
||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||
}
|
||||
// Past-date lock on both endpoints — a range that starts today but
|
||||
// extends into the past would still lock past days in the grid.
|
||||
const lockFrom = assertNotPastDate(input.date_from, force);
|
||||
if (lockFrom) return lockFrom;
|
||||
const lockTo = assertNotPastDate(input.date_to, force);
|
||||
if (lockTo) return lockTo;
|
||||
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
|
||||
const created = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: input.user_id,
|
||||
date_from: toDateOnly(input.date_from),
|
||||
date_to: toDateOnly(input.date_to),
|
||||
project_id: input.project_id ?? null,
|
||||
category: input.category as any,
|
||||
note: input.note,
|
||||
created_by: actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
created.user_id,
|
||||
created.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(created.category),
|
||||
projectName,
|
||||
dateFrom: input.date_from,
|
||||
dateTo: input.date_to,
|
||||
force,
|
||||
});
|
||||
|
||||
return { data: created, oldData: null, description };
|
||||
}
|
||||
|
||||
/** Update a work_plan_entries row. Partial update. */
|
||||
export async function updateEntry(
|
||||
id: number,
|
||||
input: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
project_id?: number | null;
|
||||
category?: string;
|
||||
note?: string;
|
||||
},
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<unknown>> {
|
||||
const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
|
||||
if (!existing || existing.is_deleted) {
|
||||
return { error: "Záznam nenalezen", status: 404 };
|
||||
}
|
||||
|
||||
// Past-date lock on both endpoints (using either the new value if
|
||||
// provided, or the existing value as a fallback).
|
||||
const fromStr =
|
||||
input.date_from ?? existing.date_from.toISOString().slice(0, 10);
|
||||
const toStr = input.date_to ?? existing.date_to.toISOString().slice(0, 10);
|
||||
const lockFrom = assertNotPastDate(fromStr, force);
|
||||
if (lockFrom) return lockFrom;
|
||||
const lockTo = assertNotPastDate(toStr, force);
|
||||
if (lockTo) return lockTo;
|
||||
|
||||
if (input.date_from && input.date_to && input.date_to < input.date_from) {
|
||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||
}
|
||||
|
||||
// Only validate the category when it actually changes — so editing just the
|
||||
// note of an entry whose category was later retired/hidden still saves.
|
||||
if (input.category && input.category !== existing.category) {
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
}
|
||||
|
||||
const updated = await prisma.work_plan_entries.update({
|
||||
where: { id },
|
||||
data: {
|
||||
date_from: input.date_from ? toDateOnly(input.date_from) : undefined,
|
||||
date_to: input.date_to ? toDateOnly(input.date_to) : undefined,
|
||||
project_id: input.project_id === undefined ? undefined : input.project_id,
|
||||
category: input.category as any,
|
||||
note: input.note,
|
||||
},
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
updated.user_id,
|
||||
updated.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(updated.category),
|
||||
projectName,
|
||||
dateFrom: isoDay(updated.date_from),
|
||||
dateTo: isoDay(updated.date_to),
|
||||
force,
|
||||
});
|
||||
|
||||
return { data: updated, oldData: existing, description };
|
||||
}
|
||||
|
||||
/** Soft-delete a work_plan_entries row. */
|
||||
export async function deleteEntry(
|
||||
id: number,
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<{ ok: true }>> {
|
||||
const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
|
||||
if (!existing || existing.is_deleted) {
|
||||
return { error: "Záznam nenalezen", status: 404 };
|
||||
}
|
||||
const fromStr = existing.date_from.toISOString().slice(0, 10);
|
||||
const lock = assertNotPastDate(fromStr, force);
|
||||
if (lock) return lock;
|
||||
|
||||
await prisma.work_plan_entries.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true },
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
existing.user_id,
|
||||
existing.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(existing.category),
|
||||
projectName,
|
||||
dateFrom: isoDay(existing.date_from),
|
||||
dateTo: isoDay(existing.date_to),
|
||||
force,
|
||||
});
|
||||
|
||||
return { data: { ok: true }, oldData: existing, description };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a work_plan_overrides row. Replaces any active override for the
|
||||
* same (user_id, shift_date) — soft-deletes the old one. Past dates
|
||||
* require force=true.
|
||||
*
|
||||
* Returns { data, oldData: null, replacedData: existing_or_null } so the
|
||||
* route handler can emit two audit log rows: one for the soft-deleted
|
||||
* previous override, and one for the new one.
|
||||
*/
|
||||
export async function createOverride(
|
||||
input: {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
},
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<any>> {
|
||||
const lock = assertNotPastDate(input.shift_date, force);
|
||||
if (lock) return lock;
|
||||
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
|
||||
// Application-level enforcement of "at most one active override per
|
||||
// (user_id, shift_date)". The previous DB unique constraint was dropped
|
||||
// (see migration 20260605122718_drop_wpo_unique_constraint) because
|
||||
// MySQL unique indexes don't ignore soft-deleted rows, which would have
|
||||
// blocked legitimate soft-delete-then-create replacement.
|
||||
//
|
||||
// Race protection: wrap the soft-delete + create in a transaction with
|
||||
// SELECT ... FOR UPDATE on the colliding row so two concurrent requests
|
||||
// can't both create active overrides for the same user-day.
|
||||
const date = toDateOnly(input.shift_date);
|
||||
|
||||
const { created, replaced } = await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.work_plan_overrides.findFirst({
|
||||
where: {
|
||||
user_id: input.user_id,
|
||||
shift_date: date,
|
||||
is_deleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Lock the row before update so a concurrent createOverride for the
|
||||
// same (user, date) waits and sees the soft-deleted state.
|
||||
await tx.$executeRaw`SELECT id FROM work_plan_overrides WHERE id = ${existing.id} FOR UPDATE`;
|
||||
await tx.work_plan_overrides.update({
|
||||
where: { id: existing.id },
|
||||
data: { is_deleted: true },
|
||||
});
|
||||
}
|
||||
|
||||
const createdRow = await tx.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: input.user_id,
|
||||
shift_date: date,
|
||||
project_id: input.project_id ?? null,
|
||||
category: input.category as any,
|
||||
note: input.note,
|
||||
created_by: actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return { created: createdRow, replaced: existing };
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
created.user_id,
|
||||
created.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(created.category),
|
||||
projectName,
|
||||
dateFrom: input.shift_date,
|
||||
dateTo: input.shift_date,
|
||||
force,
|
||||
});
|
||||
|
||||
let replacedDescription: string | undefined;
|
||||
if (replaced) {
|
||||
const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
|
||||
replacedDescription = buildPlanAuditDescription({
|
||||
userName: r.userName,
|
||||
categoryLabel: await resolveCategoryLabel(replaced.category),
|
||||
projectName: r.projectName,
|
||||
dateFrom: isoDay(replaced.shift_date),
|
||||
dateTo: isoDay(replaced.shift_date),
|
||||
suffix: "nahrazeno novým záznamem",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: created,
|
||||
oldData: null,
|
||||
replacedData: replaced,
|
||||
description,
|
||||
replacedDescription,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update a work_plan_overrides row. */
|
||||
export async function updateOverride(
|
||||
id: number,
|
||||
input: { project_id?: number | null; category?: string; note?: string },
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<any>> {
|
||||
const existing = await prisma.work_plan_overrides.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing || existing.is_deleted) {
|
||||
return { error: "Záznam nenalezen", status: 404 };
|
||||
}
|
||||
const dateStr = existing.shift_date.toISOString().slice(0, 10);
|
||||
const lock = assertNotPastDate(dateStr, force);
|
||||
if (lock) return lock;
|
||||
|
||||
// Only validate the category when it actually changes (see updateEntry).
|
||||
if (input.category && input.category !== existing.category) {
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
}
|
||||
|
||||
const updated = await prisma.work_plan_overrides.update({
|
||||
where: { id },
|
||||
data: {
|
||||
project_id: input.project_id === undefined ? undefined : input.project_id,
|
||||
category: input.category as any,
|
||||
note: input.note,
|
||||
},
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
updated.user_id,
|
||||
updated.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(updated.category),
|
||||
projectName,
|
||||
dateFrom: isoDay(updated.shift_date),
|
||||
dateTo: isoDay(updated.shift_date),
|
||||
force,
|
||||
});
|
||||
|
||||
return { data: updated, oldData: existing, description };
|
||||
}
|
||||
|
||||
/** Soft-delete a work_plan_overrides row. */
|
||||
export async function deleteOverride(
|
||||
id: number,
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<{ ok: true }>> {
|
||||
const existing = await prisma.work_plan_overrides.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing || existing.is_deleted) {
|
||||
return { error: "Záznam nenalezen", status: 404 };
|
||||
}
|
||||
const dateStr = existing.shift_date.toISOString().slice(0, 10);
|
||||
const lock = assertNotPastDate(dateStr, force);
|
||||
if (lock) return lock;
|
||||
|
||||
await prisma.work_plan_overrides.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true },
|
||||
});
|
||||
|
||||
const { userName, projectName } = await resolvePlanLabels(
|
||||
existing.user_id,
|
||||
existing.project_id,
|
||||
);
|
||||
const description = buildPlanAuditDescription({
|
||||
userName,
|
||||
categoryLabel: await resolveCategoryLabel(existing.category),
|
||||
projectName,
|
||||
dateFrom: isoDay(existing.shift_date),
|
||||
dateTo: isoDay(existing.shift_date),
|
||||
force,
|
||||
});
|
||||
|
||||
return { data: { ok: true }, oldData: existing, description };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw-row list endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** List raw work_plan_entries rows in a date range, excluding soft-deleted.
|
||||
* When isAdmin is false, the user_id filter is forced to the actor's own id
|
||||
* (so a non-admin cannot read another user's plan via the user_id query param). */
|
||||
export async function listEntries(
|
||||
query: { user_id?: number; date_from: string; date_to: string },
|
||||
actorUserId: number,
|
||||
isAdmin: boolean,
|
||||
) {
|
||||
const effectiveUserId = isAdmin ? query.user_id : actorUserId;
|
||||
return prisma.work_plan_entries.findMany({
|
||||
where: {
|
||||
user_id: effectiveUserId,
|
||||
is_deleted: false,
|
||||
date_from: { lte: toDateOnly(query.date_to) },
|
||||
date_to: { gte: toDateOnly(query.date_from) },
|
||||
},
|
||||
orderBy: [{ date_from: "asc" }, { id: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
/** List raw work_plan_overrides rows in a date range, excluding soft-deleted.
|
||||
* Same employee-scoping rules as listEntries. */
|
||||
export async function listOverrides(
|
||||
query: { user_id?: number; date_from: string; date_to: string },
|
||||
actorUserId: number,
|
||||
isAdmin: boolean,
|
||||
) {
|
||||
const effectiveUserId = isAdmin ? query.user_id : actorUserId;
|
||||
return prisma.work_plan_overrides.findMany({
|
||||
where: {
|
||||
user_id: effectiveUserId,
|
||||
is_deleted: false,
|
||||
shift_date: {
|
||||
gte: toDateOnly(query.date_from),
|
||||
lte: toDateOnly(query.date_to),
|
||||
},
|
||||
},
|
||||
orderBy: [{ shift_date: "asc" }, { id: "asc" }],
|
||||
});
|
||||
}
|
||||
165
src/services/planCategory.service.ts
Normal file
165
src/services/planCategory.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import prisma from "../config/database";
|
||||
|
||||
export type CatResult<T> = { data: T } | { error: string; status: number };
|
||||
|
||||
/** ASCII slug: strip diacritics, lowercase, non-alphanumerics → underscore. */
|
||||
export function slugifyCategory(label: string): string {
|
||||
return label
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export async function listPlanCategories(includeInactive = true) {
|
||||
return prisma.plan_categories.findMany({
|
||||
where: includeInactive ? {} : { is_active: true },
|
||||
orderBy: [{ sort_order: "asc" }, { id: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function isCategoryKeyActive(key: string): Promise<boolean> {
|
||||
const cat = await prisma.plan_categories.findFirst({
|
||||
where: { key, is_active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
return cat !== null;
|
||||
}
|
||||
|
||||
async function uniqueKey(base: string): Promise<string> {
|
||||
const root = base || "kategorie";
|
||||
let candidate = root;
|
||||
let n = 2;
|
||||
while (
|
||||
await prisma.plan_categories.findUnique({
|
||||
where: { key: candidate },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
candidate = `${root}_${n++}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function createPlanCategory(input: {
|
||||
label: string;
|
||||
color: string;
|
||||
}): Promise<
|
||||
CatResult<{
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}>
|
||||
> {
|
||||
const key = await uniqueKey(slugifyCategory(input.label));
|
||||
const max = await prisma.plan_categories.aggregate({
|
||||
_max: { sort_order: true },
|
||||
});
|
||||
const sort_order = (max._max.sort_order ?? 0) + 1;
|
||||
const data = await prisma.plan_categories.create({
|
||||
data: {
|
||||
key,
|
||||
label: input.label,
|
||||
color: input.color,
|
||||
sort_order,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
|
||||
export async function updatePlanCategory(
|
||||
id: number,
|
||||
input: { label?: string; color?: string; is_active?: boolean },
|
||||
): Promise<
|
||||
CatResult<{
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}>
|
||||
> {
|
||||
const existing = await prisma.plan_categories.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "Kategorie nenalezena", status: 404 };
|
||||
|
||||
// Guard: never deactivate the last active category.
|
||||
if (input.is_active === false && existing.is_active) {
|
||||
const activeCount = await prisma.plan_categories.count({
|
||||
where: { is_active: true },
|
||||
});
|
||||
if (activeCount <= 1) {
|
||||
return {
|
||||
error: "Musí zůstat alespoň jedna aktivní kategorie",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const data = await prisma.plan_categories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
label: input.label ?? undefined,
|
||||
color: input.color ?? undefined,
|
||||
is_active: input.is_active ?? undefined,
|
||||
},
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
|
||||
export async function deactivatePlanCategory(
|
||||
id: number,
|
||||
): Promise<CatResult<{ ok: true }>> {
|
||||
const res = await updatePlanCategory(id, { is_active: false });
|
||||
if ("error" in res) return res;
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete a category. Blocked when the category is still used by any live
|
||||
* (non-soft-deleted) plan entry or override — those rows would otherwise lose
|
||||
* their label/color — in which case the caller should hide it instead. Also
|
||||
* refuses to remove the last active category so the create form is never empty.
|
||||
*/
|
||||
export async function deletePlanCategory(
|
||||
id: number,
|
||||
): Promise<CatResult<{ ok: true }>> {
|
||||
const existing = await prisma.plan_categories.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "Kategorie nenalezena", status: 404 };
|
||||
|
||||
const [entryCount, overrideCount] = await Promise.all([
|
||||
prisma.work_plan_entries.count({
|
||||
where: { category: existing.key, is_deleted: false },
|
||||
}),
|
||||
prisma.work_plan_overrides.count({
|
||||
where: { category: existing.key, is_deleted: false },
|
||||
}),
|
||||
]);
|
||||
if (entryCount + overrideCount > 0) {
|
||||
return {
|
||||
error:
|
||||
"Kategorie je používána v plánu a nelze ji smazat. Můžete ji skrýt.",
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing.is_active) {
|
||||
const activeCount = await prisma.plan_categories.count({
|
||||
where: { is_active: true },
|
||||
});
|
||||
if (activeCount <= 1) {
|
||||
return {
|
||||
error: "Musí zůstat alespoň jedna aktivní kategorie",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.plan_categories.delete({ where: { id } });
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
@@ -143,4 +143,7 @@ export type EntityType =
|
||||
| "warehouse_supplier"
|
||||
| "warehouse_category"
|
||||
| "warehouse_location"
|
||||
| "warehouse_receipt_attachment";
|
||||
| "warehouse_receipt_attachment"
|
||||
| "work_plan_entry"
|
||||
| "work_plan_override"
|
||||
| "plan_category";
|
||||
|
||||
90
src/utils/planAuditDescription.ts
Normal file
90
src/utils/planAuditDescription.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Human-readable Czech descriptions for work-plan audit-log rows.
|
||||
*
|
||||
* The audit log table ("Popis" column) shows only the `description` string,
|
||||
* so it must be self-contained: who, when, what. Previously the plan routes
|
||||
* left `description` undefined for normal edits, which rendered as "-" and
|
||||
* made the audit rows useless. These helpers build a readable subject line
|
||||
* such as: `Jan Novák · 08.06.2026 – 12.06.2026 · Práce · Projekt ABC`.
|
||||
*
|
||||
* Everything here is pure (no Prisma, no Date.now) so it is trivially unit
|
||||
* testable. The service layer resolves user/project names and feeds plain
|
||||
* strings in.
|
||||
*/
|
||||
|
||||
/** Czech labels for the `plan_category` enum. Mirrors the frontend
|
||||
* PlanCellModal options so the audit log reads the same as the UI. */
|
||||
export const PLAN_CATEGORY_LABELS_CS: Record<string, string> = {
|
||||
work: "Práce",
|
||||
preparation: "Příprava",
|
||||
travel: "Cesta / Montáž",
|
||||
leave: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
training: "Školení",
|
||||
other: "Jiné",
|
||||
};
|
||||
|
||||
/** Translate a plan category to its Czech label, falling back to the raw
|
||||
* value for unknown categories so nothing is ever silently dropped. */
|
||||
export function planCategoryLabel(category: string): string {
|
||||
return PLAN_CATEGORY_LABELS_CS[category] ?? category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a `YYYY-MM-DD` string as Czech `DD.MM.YYYY`.
|
||||
*
|
||||
* Takes a string (not a Date) on purpose: work-plan dates live in `@db.Date`
|
||||
* columns and the service derives the ISO day via `toISOString().slice(0,10)`,
|
||||
* so there is no timezone ambiguity to reintroduce here. Matches the
|
||||
* `localDateCzStr` style in `src/utils/date.ts`.
|
||||
*/
|
||||
export function formatPlanDate(iso: string): string {
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export interface PlanAuditDescriptionInput {
|
||||
/** Employee the plan row belongs to (display name). */
|
||||
userName: string;
|
||||
/** Already-resolved Czech category label (resolved from plan_categories). */
|
||||
categoryLabel: string;
|
||||
/** Resolved project name, or null when the row has no project. */
|
||||
projectName?: string | null;
|
||||
/** Range start (YYYY-MM-DD). For overrides, pass the shift date here. */
|
||||
dateFrom: string;
|
||||
/** Range end (YYYY-MM-DD). For overrides/single days, equals `dateFrom`. */
|
||||
dateTo: string;
|
||||
/** Append "nouzová úprava" when the change bypassed the past-date lock. */
|
||||
force?: boolean;
|
||||
/** Extra Czech note appended at the end (e.g. "nahrazeno novým záznamem"). */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Czech audit description: a `·`-separated subject line. The action
|
||||
* (create/update/delete) and entity type are shown in their own audit-table
|
||||
* columns, so the description carries only the subject — never the verb.
|
||||
*/
|
||||
export function buildPlanAuditDescription(
|
||||
opts: PlanAuditDescriptionInput,
|
||||
): string {
|
||||
const dateLabel =
|
||||
opts.dateFrom === opts.dateTo
|
||||
? formatPlanDate(opts.dateFrom)
|
||||
: `${formatPlanDate(opts.dateFrom)} – ${formatPlanDate(opts.dateTo)}`;
|
||||
|
||||
const parts: string[] = [opts.userName, dateLabel, opts.categoryLabel];
|
||||
|
||||
if (opts.projectName && opts.projectName.trim()) {
|
||||
parts.push(opts.projectName.trim());
|
||||
}
|
||||
if (opts.suffix && opts.suffix.trim()) {
|
||||
parts.push(opts.suffix.trim());
|
||||
}
|
||||
if (opts.force) {
|
||||
parts.push("nouzová úprava");
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
Reference in New Issue
Block a user