Compare commits

..

131 Commits

Author SHA1 Message Date
BOHA
852c127c00 chore(release): v1.9.0 — work-plan category management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:37:41 +02:00
BOHA
71a0016a4a fix(migration): create replacement index before dropping the unique one
20260605122718_drop_wpo dropped uniq_wpo_user_date (the sole index covering the user_id FK) BEFORE creating idx_wpo_user_date, which MySQL rejects on a fresh apply ('Cannot drop index ... needed in a foreign key constraint'). Reordered so the replacement index exists first. This migration is not yet applied on production, so editing it is safe; the resulting index state is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:31:36 +02:00
BOHA
88c9b7c2b8 fix(auth): stop logging expected token expiry as an error
verifyAccessToken logged every invalid/expired access token via console.error with a full stack trace. Access tokens are ~15 min and the client silently refreshes + retries on the 401, so this is an expected condition that flooded production logs (~every 15 min per active user) and buried real errors. Now only genuinely unexpected failures (non-JsonWebTokenError, e.g. a DB error in loadAuthData) are logged. No behavior change — verification still returns null on any token error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:23:56 +02:00
BOHA
4a78d31a7f fix(types): resolve all client TypeScript errors (attendance + plan rollback)
- AttendanceAdmin: declare worked_hours_raw on UserTotalData (the hook sets it at runtime; the local interface omitted it)
- usePlanWork.rollbackMutation: type the mutation param as unknown + explicit cast so passing the TanStack mutation result (with the dynamically-stashed _rolled) no longer trips the weak-type check
tsc -p tsconfig.app.json --noEmit is now clean (0 errors, was 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:20:24 +02:00
BOHA
e845400fca fix(plan): keep retired category usable when editing an entry's note
Update validates the category only when it changes, so a note-only edit on an entry whose category was later hidden/deleted no longer 400s. The edit select also shows the current (retired) category marked '(skryta)' instead of silently snapping to the first active option; create defaults to an active category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:17:23 +02:00
BOHA
fce27633a9 fix(ui): add admin-btn-danger variant so category delete confirm is styled
admin-btn-danger was referenced but never defined, so the 'Opravdu smazat' confirm rendered as a plain button. Define a solid danger button (uses --danger), matching the primary button's structure; the per-row delete trigger stays an outline via plan.css. (Includes the transparent-border button refinement so variants don't layout-shift.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:15:12 +02:00
BOHA
4d47897e27 feat(plan): add hard-delete for categories (blocked when in use)
DELETE /plan/categories/:id now hard-deletes the row, guarded: refuses when any live entry/override uses the key (suggests hide instead) and keeps >=1 active category. Modal gains a per-row Smazat with inline confirm. Hiding stays via PATCH is_active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:12:40 +02:00
BOHA
aef4ab92ec feat(plan): label plan_category audit rows in Czech
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:00:30 +02:00
BOHA
5f36c34d48 refactor(plan): commit category color on blur to avoid PATCH/audit spam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:59:41 +02:00
BOHA
b94f4879e3 feat(plan): add Sprava kategorii management modal and button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:55:20 +02:00
BOHA
dab5ad8aa5 feat(plan): drive category select and view label from DB categories
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:52:07 +02:00
BOHA
8b77a0a905 feat(plan): render category colors from DB via --cat-color
Replace 14 hardcoded per-category CSS rules with a single --cat-color
custom property set inline on each filled cell button, driven by the DB
categories list threaded from PlanWork → PlanGrid → PlanRangeChips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:47:53 +02:00
BOHA
4b1c42b17f feat(plan): add categories query, type, and map helpers (frontend)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:44:28 +02:00
BOHA
a55356d417 feat(plan): validate dynamic category keys, resolve audit label from DB
- Replace hardcoded planCategoryEnum z.enum with z.string().trim().min(1)
- Add assertActiveCategory() helper that rejects inactive/unknown keys
- Add resolveCategoryLabel() helper that resolves the Czech label from plan_categories DB table, falling back to built-in map
- Change PlanAuditDescriptionInput.category -> categoryLabel (pre-resolved string)
- Update buildPlanAuditDescription to use opts.categoryLabel directly
- Update all 6 buildPlanAuditDescription call sites in plan.service.ts to await resolveCategoryLabel(row.category)
- Add category validation in createEntry, updateEntry, createOverride, updateOverride
- Update planAuditDescription tests to pass categoryLabel instead of category

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:41:50 +02:00
BOHA
c983747418 feat(plan): add plan category CRUD routes with audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:38:25 +02:00
BOHA
3db257faa9 feat(plan): add plan category service with tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:35:32 +02:00
BOHA
d3d7f7e199 feat(plan): add plan category zod schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:33:47 +02:00
BOHA
084ec9289b feat(plan): add plan_categories table, migrate category enum to varchar
- Add `plan_categories` model (id, key, label, color, sort_order, is_active)
- Change `work_plan_entries.category` from ENUM plan_category to VARCHAR(50)
- Change `work_plan_overrides.category` from ENUM plan_category to VARCHAR(50)
- Remove the `plan_category` enum from schema
- Seed 7 built-in categories (work, preparation, travel, leave, sick, training, other)

Also fixes two pre-existing migration issues that blocked shadow-DB execution:
- 20260605122718: swap CREATE INDEX before DROP INDEX so MySQL FK constraint is not broken
- 20260514071455_init: fix quotations.project_code VARCHAR(50→100) to match actual DB schema

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:32:02 +02:00
BOHA
4a9c283789 fix(plan): readable audit descriptions, project labels, dashboard invalidation, view-only & sticky-column UI
Work-plan fixes from this session:
- Czech audit descriptions for plan entries/overrides (was empty '-')
- server-embedded project label on grid cells (was '-' past the 100-project cap)
- dashboard activity + audit-log cache invalidation on plan mutations
- read-only cells: no '+' on empty, keep hover on cells with a record
- view modal: Czech category + formatted dates
- sticky date column made opaque (no bleed-through on horizontal scroll)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:24:33 +02:00
BOHA
2f76fe0bc1 fix(plan): add 'Upravit celý rozsah' button, use class-based action rows 2026-06-05 13:58:07 +02:00
BOHA
9a11a8f001 feat(plan): show project name instead of raw id in cell 2026-06-05 13:51:27 +02:00
BOHA
f0816b126b fix(plan): make mutations throw on non-2xx so errors surface as alerts 2026-06-05 13:46:50 +02:00
BOHA
f31797fb5b fix(plan): allow empty notes, use local time for past-date gate
Three related fixes from manual smoke testing:
- Zod schema: drop min(1) on note field across all four schemas — note is
  optional in real usage, and the modal initialized it to empty.
- assertNotPastDate: replace toISOString() (UTC) with local-time getters
  to match the rest of the codebase. Fixes off-by-one near day boundaries.
- createEntry / updateEntry: also check the date_to endpoint for past
  dates, so a range can't extend backwards into the past even when its
  start is today or future. Defense in depth.

Modal: drop `required` attribute and asterisk from the note field. Tests:
add 5 new cases covering empty note, past date HTTP rejection, and the
new to-endpoint check in updateEntry.
2026-06-05 13:42:21 +02:00
BOHA
9c110abf46 fix(plan): narrow mode in EditForm IIFE and style plan-banner 2026-06-05 13:21:06 +02:00
BOHA
a0f351b0aa feat(plan): page, sidebar entry, and route registration 2026-06-05 13:17:16 +02:00
BOHA
916cf34efd feat(plan): edit modal with create / edit-range / override / view modes 2026-06-05 13:02:52 +02:00
BOHA
003807a074 feat(plan): grid component with sticky headers and weekend tint 2026-06-05 12:57:41 +02:00
BOHA
ca911bf96c feat(plan): react query options and page state hook 2026-06-05 12:54:50 +02:00
BOHA
9c10ff9f22 test(plan): HTTP route tests with auth, scoping, and force flag 2026-06-05 12:51:20 +02:00
BOHA
38a58b55c0 feat(plan): wire plan routes into the server 2026-06-05 12:42:53 +02:00
BOHA
4d8fd62961 feat(plan): REST routes for plan endpoints 2026-06-05 12:40:25 +02:00
BOHA
ef404e4362 feat(plan): listEntries/listOverrides with employee scoping 2026-06-05 12:33:20 +02:00
BOHA
fb022802ae feat(plan): override CRUD with replace-existing behavior 2026-06-05 12:30:27 +02:00
BOHA
795d4d5af9 feat(plan): entry CRUD with past-date lock and audit-data return 2026-06-05 12:19:21 +02:00
BOHA
74113035ae feat(plan): assertNotPastDate guard for write paths 2026-06-05 12:15:39 +02:00
BOHA
42f9561337 feat(plan): listPlanUsers with role/permission filter 2026-06-05 12:13:16 +02:00
BOHA
f79967d433 feat(plan): resolveGrid for batch cell resolution 2026-06-05 12:10:30 +02:00
BOHA
d410e7433e feat(plan): extend EntityType with work_plan_entry and work_plan_override 2026-06-05 12:07:48 +02:00
BOHA
0711bafac0 feat(plan): resolveCell with override-precedence semantics 2026-06-05 11:55:30 +02:00
BOHA
5273f88272 feat(plan): zod schemas for plan endpoints 2026-06-05 11:47:34 +02:00
BOHA
182d4ca4b3 feat(plan): add work_plan_entries, work_plan_overrides, plan_category enum 2026-06-05 11:44:08 +02:00
Claude
4773b5be22 docs(spec): Plán prací (work schedule) module design 2026-06-05 11:05:56 +02:00
BOHA
6074d6163b docs(release): document post-tarball migration hotfix procedure 2026-06-03 23:27:44 +02:00
BOHA
652e3f91fc fix(warehouse): add migration for warehouse permissions
The warehouse module v1.8.0 deploy created the warehouse tables via Prisma
migration but left the corresponding permissions (warehouse.view/operate/
manage/inventory) and admin role assignments un-inserted, because those
rows only existed in prisma/seed.ts, which is dev-only and cannot be run
against production.

This migration inserts the same 4 permissions seed.ts would (with
identical display_name and description) and assigns them to the admin
role. INSERT IGNORE makes it idempotent and preserves existing role
assignments. No data is modified or deleted.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Moved all useCallback definitions before the conditional returns.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:17:20 +02:00
BOHA
07cb428287 1.5.2
- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:23:10 +02:00
BOHA
b197017644 1.5.1 2026-04-02 20:01:44 +02:00
BOHA
e9f07a4a39 fix: invoice edit/list improvements
- Due date uses days selector in edit mode (same as create)
- Overdue invoices fully editable (same as issued)
- Overdue status reversed to issued when due date moved to future
- Invoice list: edit icon for issued/overdue, eye for paid
- Invoice list: PDF opens blob from NAS (removed lang modal)
- NAS cleanup: properly scans directories when cleaning old PDFs
- Fixed syntax error from leftover else block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:01:43 +02:00
BOHA
44d389201c 1.5.0 2026-04-02 15:47:46 +02:00
BOHA
3106aaf314 feat: full invoice editing before payment, NAS cleanup on date change
- Invoice edit mode now uses the same form as create mode (all fields editable)
- Bank account pre-selected by matching IBAN/account number
- Invoice number read-only in edit mode
- Paid invoices remain read-only
- NAS: old PDF deleted when invoice date changes to different month
- Buttons: Zobrazit fakturu, Uložit, Smazat + status transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:47:46 +02:00
BOHA
90e797b8fa 1.4.9 2026-04-02 15:25:35 +02:00
BOHA
1f7362c8af fix: invoice PDF — tighter layout, more room for items
- Page margins reduced, content width 186mm
- Header/grid padding tightened
- Table headers 8.5pt normal case, cells 4px padding
- Footer flows naturally across pages (no forced page break)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:25:35 +02:00
BOHA
fe44a2b12d 1.4.8 2026-04-02 12:55:24 +02:00
BOHA
8a9239311d feat: invoice PDF — larger fonts, order number and date in dates column
- Base font 9pt→10pt, all sub-elements scaled proportionally
- Order number and date shown in dates column when invoice linked to order
- Uses customer_order_number with fallback to internal order_number

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:24 +02:00
BOHA
cd25cd6ee4 1.4.7 2026-04-02 12:31:51 +02:00
BOHA
967fbba2a4 fix: invoice PDF footer — single line with space for signatures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:31:51 +02:00
BOHA
41fe65c7fc 1.4.6 2026-04-02 12:01:52 +02:00
BOHA
09d345a312 fix: invoice PDF table — numbers 8pt, description column wider (36%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:01:51 +02:00
BOHA
1a13d745f1 1.4.5 2026-04-02 11:56:06 +02:00
BOHA
ce184771a6 feat: invoice PDF redesign — professional table-based layout
- Header with red accent border, larger invoice number
- Address blocks in connected table grid with equal heights
- Customer and bank info highlighted with gray background
- Bank info uses same row layout as dates (aligned labels/values)
- Labels nowrap, values right-aligned
- Item font size 8pt, table header border gray
- Removed duplicate separator lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:56:05 +02:00
BOHA
7b6365f6b3 1.4.4 2026-04-02 11:28:13 +02:00
BOHA
44867c79f8 fix: PDF item names bold on Linux — font-weight 500→600
Linux lacks Segoe UI semibold, so weight 500 rendered as regular.
Changed to 600 which maps to bold on both Windows and Linux.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:28:12 +02:00
BOHA
09a9e8c2f0 1.4.3 2026-04-02 11:13:30 +02:00
BOHA
b26a6f40b9 fix: invoice PDF shows unit next to quantity (e.g. 193,50 / ks)
Adjusted column widths to prevent header overlap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:13:29 +02:00
BOHA
40cb5a4d76 1.4.2 2026-04-02 11:05:42 +02:00
BOHA
ecd97ae5a3 fix: bulk attendance fill creates holiday records instead of skipping
Holidays now get leave_type: "holiday" with 8h so they count in fund calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:05:42 +02:00
BOHA
d14e97d7bd 1.4.1 2026-04-02 10:56:26 +02:00
BOHA
ef891f8e01 fix: bulk attendance fill — accept string user_ids, skip holidays
- Schema now accepts both string and number user_ids (frontend sends strings)
- Bulk fill now skips Czech public holidays in addition to weekends

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:56:25 +02:00
BOHA
96ba5d034f 1.4.0 2026-03-28 09:03:06 +01:00
BOHA
2402b7cbc8 fix: "Moje žádosti" page shows only current user's requests
Admins were seeing all requests on their own requests page.
Added mine=1 param to force user_id filter regardless of role.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:03:05 +01:00
BOHA
79b2fa5570 1.3.9 2026-03-28 08:56:14 +01:00
BOHA
35fa172d36 fix: trips admin shows only users with trips.record permission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:56:14 +01:00
BOHA
000a77ccf4 1.3.8 2026-03-27 21:27:16 +01:00
BOHA
ecd9f6a181 chore: fix npm audit vulnerabilities (brace-expansion, fastify, nodemailer, picomatch)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:27:14 +01:00
BOHA
68e6d80903 1.3.7 2026-03-27 17:32:22 +01:00
BOHA
af1b41994c fix: attendance shows only users with attendance.record permission
- Filter attendance admin/balances/workfund to users with attendance.record
  permission or admin role
- New attendance_users API action for user dropdown
- Fix missing prisma import in attendance route
- Fix user edit: empty password no longer blocks save (preprocess to undefined)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:32:22 +01:00
BOHA
9779112066 1.3.6 2026-03-27 13:50:00 +01:00
BOHA
e8d6dc1567 fix: dashboard offers card showing wrong counts
Queried status "converted"/"expired" but actual DB values are
"ordered"/"invalidated". Updated label "Prošlé" → "Zneplatněné".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:50:00 +01:00
BOHA
f9dd49591e 1.3.5 2026-03-27 13:44:54 +01:00
BOHA
8cdf057ab3 feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK
- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:44:53 +01:00
230 changed files with 39831 additions and 14756 deletions

View File

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

View File

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

439
CLAUDE.md Normal file
View File

@@ -0,0 +1,439 @@
# CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
---
## Tech Stack
| Layer | Technology |
| -------------- | ------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
---
## Project Structure
```
src/
├── server.ts # Fastify server entry point — plugins, routes, error handler
├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
├── schemas/ # Zod validation schemas (one file per entity)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
│ # security.ts (CSP, HSTS, security headers)
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 frontend (57 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
│ ├── pages/ # One file per page/feature
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (auth, numbering)
prisma/
├── schema.prisma # 32 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
```
---
## Commands
```bash
# Development
npm run dev # Starts server in watch mode (manage frontend separately)
npm run dev:server # tsx watch src/server.ts
npm run dev:client # Vite dev server
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass)
npm run test:watch # vitest watch
# Database
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
```
**Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
---
## Environment Variables
Required:
```
DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>
```
Optional (with defaults):
```
PORT=3001 # Production port (dev default: 3000)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
```
Use `.env` for dev, `.env.test` for tests.
---
## Architecture & Key Patterns
### Request Flow
```
Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth()
→ Zod schema validation (parseBody helper)
→ Route handler
→ Service function
→ Prisma
→ success(reply, data) or error(reply, message, status)
```
### Response Format
All responses use this shape:
```typescript
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error
{ success: false, error: string }
```
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
### Service Pattern
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
### Permissions
```typescript
// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
"preHandler",
requirePermission("invoices.view", "invoices.edit"),
);
// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit Logging
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
---
## Date & Timezone Handling (Critical Gotcha)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
`Date.prototype.toJSON()` to return local time (not UTC). This means:
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
- All API responses with Date fields will contain local time strings.
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
- **Never assume UTC** when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
- The override exists for PHP migration compatibility and Czech date display.
---
## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
---
## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
- Test coverage is minimal: only `auth` and `numbering` are tested.
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
---
## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
### Query Invalidation Convention
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
- `["users"]` over `["users", "list"]`
- `["trips"]` over `["trips", "vehicles"]`
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
---
## Database Conventions
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
**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
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
```
### Deploying migrations to production
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
---
## Known Issues & Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
4. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
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
1. Bump version in `package.json`
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
### 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.

37
boneyard.config.json Normal file
View File

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

View 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).

953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.3.4",
"version": "1.9.0",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -17,7 +17,11 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"keywords": [],
"author": "",
@@ -34,6 +38,8 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
@@ -42,6 +48,7 @@
"file-type": "^16.5.4",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"node-cron": "^4.2.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
);

View 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;

View File

@@ -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`;

View File

@@ -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);

View File

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

View File

@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id")
@@ -100,18 +101,18 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes?
logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
@@ -127,6 +128,12 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
}
model customers {
@@ -152,8 +159,9 @@ model customers {
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -165,7 +173,7 @@ model invoice_items {
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50)
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -196,6 +204,7 @@ model invoices {
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id")
}
@@ -253,6 +262,8 @@ model number_sequences {
type String? @db.VarChar(50)
year Int?
last_number Int? @default(0)
@@unique([type, year], map: "idx_number_sequences_type_year")
}
model order_items {
@@ -286,7 +297,7 @@ model order_sections {
model orders {
id Int @id @default(autoincrement())
order_number String? @db.VarChar(50)
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
customer_order_number String? @db.VarChar(100)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -338,7 +349,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -350,7 +361,12 @@ model projects {
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
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")
@@ -372,10 +388,7 @@ model quotation_items {
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -383,8 +396,8 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50)
project_code String? @db.VarChar(50)
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
@@ -392,17 +405,13 @@ model quotations {
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
@@ -487,11 +496,7 @@ model scope_sections {
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
content_editor_height Int?
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -578,12 +583,20 @@ model users {
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[]
trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
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")
@@ -624,8 +637,9 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
}
enum received_invoices_status {
@@ -640,3 +654,324 @@ enum attendance_leave_type {
holiday
unpaid
}
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
reservations sklad_reservations[]
inventory_lines sklad_inventory_lines[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
items sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}
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")
}

469
prisma/seed.ts Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,50 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
generateSharedNumber,
generateOfferNumber,
} from "../services/numbering.service";
import prisma from "../config/database";
describe("generateSharedNumber", () => {
it("returns correct format (YYtypeCode + 4 digits)", async () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
it("returns a non-empty string", async () => {
const num = await generateSharedNumber();
const yy = String(new Date().getFullYear()).slice(-2);
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
const num1 = await generateSharedNumber();
const num2 = await generateSharedNumber();
expect(num1).not.toBe(num2);
});
});
describe("generateOfferNumber", () => {
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
it("returns a non-empty string", async () => {
const num = await generateOfferNumber();
const year = new Date().getFullYear();
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
const num1 = await generateOfferNumber();
const num2 = await generateOfferNumber();
expect(num1).not.toBe(num2);
});
});

886
src/__tests__/plan.test.ts Normal file
View 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);
});
});

View 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");
});
});

View 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 } });
}
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { CreateOrderSchema } from "../schemas/orders.schema";
import { CreateQuotationSchema } from "../schemas/offers.schema";
describe("Zod NaN rejection", () => {
describe("CreateOrderSchema", () => {
it("rejects NaN string in quantity", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: "not-a-number" }],
});
expect(result.success).toBe(false);
});
it("rejects NaN string in unit_price", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ unit_price: "abc" }],
});
expect(result.success).toBe(false);
});
it("accepts valid numbers", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: 2, unit_price: 100 }],
});
expect(result.success).toBe(true);
});
});
describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
items: [{ quantity: "bad" }],
});
expect(result.success).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -330,15 +330,6 @@ img {
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */
/* Font sizes */
@@ -412,4 +403,9 @@ img {
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.admin-spinner {
animation-duration: 0.8s !important;
animation-iteration-count: infinite !important;
}
}

View File

@@ -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;

View File

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

View File

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

View File

@@ -8,34 +8,13 @@ import {
getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number;
shift_date: string;
user_name: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
interface AttendanceRecord extends HookAttendanceRecord {
arrival_lat?: number | string | null;
arrival_lng?: number | string | null;
departure_lat?: number | string | null;
departure_lng?: number | string | null;
project_name?: string;
project_logs?: ProjectLog[];
notes?: string | null;
}
interface AttendanceShiftTableProps {
@@ -51,7 +30,7 @@ function formatBreak(record: AttendanceRecord): string {
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`;
}
return "\u2014";
return "";
}
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
@@ -64,21 +43,30 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
let h: number,
m: number,
isActive = false;
let durationValid = true;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
const start = log.started_at ? new Date(log.started_at) : null;
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
const mins = Math.max(
0,
Math.floor((end.getTime() - start.getTime()) / 60000),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
}
return (
<span
key={log.id || i}
key={log.id ?? i}
className="admin-badge"
style={{
fontSize: "0.7rem",
@@ -86,8 +74,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"}
</span>
);
})}
@@ -104,7 +94,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
</span>
);
}
return "\u2014";
return "";
}
export default function AttendanceShiftTable({
@@ -143,8 +133,12 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
@@ -161,18 +155,16 @@ export default function AttendanceShiftTable({
</span>
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
{isLeave ? "" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatBreak(record)}
{isLeave ? "" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
{isLeave ? "" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? `${formatMinutes(workMinutes)} h`
: "\u2014"}
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
@@ -183,10 +175,10 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
{"\uD83D\uDCCD"}
<span aria-hidden="true">{"📍"}</span>
</Link>
) : (
"\u2014"
""
)}
</td>
<td

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,396 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (
lang: string,
applyVat: boolean,
items?: ConfirmationItem[],
) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
isOpen,
onClose,
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const handleEditGenerate = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, items);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const updateItem = useCallback(
(
index: number,
field: keyof ConfirmationItem,
value: string | number | boolean,
) => {
setItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
},
[],
);
const removeItem = useCallback((index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
}, []);
const addItem = useCallback(() => {
setItems((prev) => [
...prev,
{
description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
vat_rate: defaultVatRate,
},
]);
}, [defaultVatRate]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Jazyk dokumentu</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setLang("cs")}
className={
lang === "cs"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Čeština
</button>
<button
type="button"
onClick={() => setLang("en")}
className={
lang === "en"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
English
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setApplyVatState(true)}
className={
applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
S DPH
</button>
<button
type="button"
onClick={() => setApplyVatState(false)}
className={
!applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Bez DPH
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Použít položky z objednávky"
)}
</button>
<button
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
className="admin-btn admin-btn-secondary w-full"
>
Upravit položky
</button>
</div>
</div>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
className="admin-form-input"
style={{ width: "60px" }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
/>
</td>
<td>
<input
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
/>
</td>
<td>
<button
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
</div>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<button
type="button"
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
)}
</button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

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

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -1,66 +1,7 @@
import { useMemo, useRef, useCallback } from "react";
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
const Quill = ReactQuill.Quill;
if (!(Quill as any).__bohaRegistered) {
const Font = Quill.import("attributors/class/font") as any;
Font.whitelist = [
"arial",
"tahoma",
"verdana",
"georgia",
"times-new-roman",
"courier-new",
"trebuchet-ms",
"impact",
"comic-sans-ms",
"lucida-console",
"palatino-linotype",
"garamond",
];
Quill.register(Font, true);
const SizeStyle = Quill.import("attributors/style/size") as any;
SizeStyle.whitelist = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
Quill.register(SizeStyle, true);
(Quill as any).__bohaRegistered = true;
}
const Font = Quill.import("attributors/class/font") as any;
const SIZE_WHITELIST = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
const COLORS = [
"#000000",
"#1a1a1a",
@@ -95,8 +36,6 @@ const COLORS = [
];
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }],
[{ list: "ordered" }, { list: "bullet" }],
@@ -107,8 +46,6 @@ const TOOLBAR = [
];
const FORMATS = [
"font",
"size",
"bold",
"italic",
"underline",
@@ -159,6 +96,13 @@ export default function RichEditor({
[onChange],
);
useLayoutEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.root.style.fontSize = "14px";
}, []);
return (
<div
className="admin-rich-editor"

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps {
mode: "create" | "edit";
show: boolean;
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
@@ -85,7 +85,15 @@ export interface ShiftFormModalProps {
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(projectLogs);
const totalProject = calcProjectMinutesTotal(
projectLogs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id);
@@ -196,7 +204,7 @@ function ProjectLogRow({
export default function ShiftFormModal({
mode,
show,
isOpen,
onClose,
onSubmit,
form,
@@ -208,7 +216,7 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show);
useModalLock(isOpen);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -240,7 +248,7 @@ export default function ShiftFormModal({
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -251,13 +259,16 @@ export default function ShiftFormModal({
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
@@ -326,7 +337,6 @@ export default function ShiftFormModal({
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"),
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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