Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d08e55a41a | ||
|
|
97101c4db7 | ||
|
|
1e4dd1fbcc | ||
|
|
5462fcf944 | ||
|
|
78cd7cf2d8 | ||
|
|
cb82349c86 | ||
|
|
d445384408 | ||
|
|
3268cf2100 | ||
|
|
841ab676ec | ||
|
|
313d9218c1 | ||
|
|
c270cb22e9 | ||
|
|
113edd9a0d | ||
|
|
753c16423c | ||
|
|
11b71501ee | ||
|
|
5c03570f07 | ||
|
|
ed9ea52a44 | ||
|
|
9e9aacdf0b | ||
|
|
52ff0a6ffa | ||
|
|
a15fa68c3b | ||
|
|
e86c2e9a80 | ||
|
|
ce636215dc | ||
|
|
473f7c4a8c | ||
|
|
28186397a0 | ||
|
|
a3948c7da1 | ||
|
|
53c8359acc | ||
|
|
c2d7a96718 | ||
|
|
61673247bf | ||
|
|
9fe5113c5b | ||
|
|
e868ecf769 | ||
|
|
20177e8f87 | ||
|
|
39565767ef | ||
|
|
c4668357aa | ||
|
|
b4f1b6ce2b | ||
|
|
66235ff567 | ||
|
|
d341db2e51 | ||
|
|
430ec3e76b | ||
|
|
f295af4a14 | ||
|
|
db11999c85 | ||
|
|
afef0e6759 | ||
|
|
62f50d31af | ||
|
|
3d3df6db85 | ||
|
|
162f9b9fdf | ||
|
|
3b48bd0523 | ||
|
|
2b7b480144 | ||
|
|
e26fce092a | ||
|
|
d7e0ba933d | ||
|
|
8b90cfed1f | ||
|
|
8476ffebd6 | ||
|
|
63ccf8fda7 | ||
|
|
9f4b8a897a | ||
|
|
2a3df15565 | ||
|
|
b1f48df30c | ||
|
|
f00fcf95fd | ||
|
|
4093664936 | ||
|
|
ecb83d4654 | ||
|
|
a06ea2e1cd | ||
|
|
15a2da38cc | ||
|
|
4072197f4a | ||
|
|
1914fddb4d | ||
|
|
63192dc781 | ||
|
|
6147131aad | ||
|
|
b4f859ea6e | ||
|
|
519edce373 | ||
|
|
c454d1a3fc | ||
|
|
b1f21a1f48 |
63
.env.example
63
.env.example
@@ -1,32 +1,63 @@
|
||||
# Database
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
|
||||
# Required vars throw at boot if missing; optional vars show their defaults.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Required ──────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/app
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local
|
||||
|
||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
PORT=3001 # production port (dev is hardcoded to 3050)
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local # local | production — controls CSP/CORS/HSTS
|
||||
APP_URL= # base URL used in email links
|
||||
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
|
||||
|
||||
# ── Tokens (seconds) ──────────────────────────────────────────────────────
|
||||
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=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
|
||||
# TOTP_ALGORITHM=SHA1
|
||||
# TOTP_DIGITS=6
|
||||
# TOTP_PERIOD=30
|
||||
# LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
# File storage
|
||||
# ── Rate limiting (optional) ──────────────────────────────────────────────
|
||||
# RATE_LIMIT_MAX=300
|
||||
# RATE_LIMIT_WINDOW=1 minute
|
||||
# RATE_LIMIT_LOGIN=20
|
||||
# RATE_LIMIT_TOTP=5
|
||||
# RATE_LIMIT_REFRESH=10
|
||||
|
||||
# ── File storage (NAS network shares) ─────────────────────────────────────
|
||||
NAS_PATH=Z:/02_PROJEKTY
|
||||
MAX_UPLOAD_SIZE=52428800
|
||||
# NAS_INVOICES= # invoice PDFs — {base}/Vydané|Přijaté/YYYY/MM/ (falls back to NAS_FINANCIALS_PATH if unset)
|
||||
# NAS_ORDERS= # issued-order PDF archival — {base}/Vydané/YYYY/MM/ (off until set)
|
||||
# NAS_OFFERS_PATH=
|
||||
MAX_UPLOAD_SIZE=52428800 # 50 MB
|
||||
|
||||
# Email
|
||||
# ── Email ─────────────────────────────────────────────────────────────────
|
||||
CONTACT_EMAIL_TO=
|
||||
CONTACT_EMAIL_FROM=
|
||||
SMTP_FROM=
|
||||
# SMTP_FROM_NAME=
|
||||
LEAVE_NOTIFY_EMAIL=
|
||||
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
|
||||
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
|
||||
# App URL (for email links)
|
||||
APP_URL=
|
||||
|
||||
# CORS (production only, comma-separated)
|
||||
# ── CORS (production only, comma-separated) ───────────────────────────────
|
||||
CORS_ORIGINS=
|
||||
|
||||
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,6 +7,13 @@ dist/
|
||||
dist-client/
|
||||
*.css.map
|
||||
|
||||
# Build / tooling artifacts
|
||||
*.tsbuildinfo
|
||||
*.bak
|
||||
|
||||
# Local scratch
|
||||
.playwright-mcp/
|
||||
|
||||
# Release archives
|
||||
*.tar.gz
|
||||
|
||||
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
dist/
|
||||
dist-client/
|
||||
node_modules/
|
||||
prisma/migrations/
|
||||
src/admin/bones/
|
||||
*.tsbuildinfo
|
||||
package-lock.json
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80
|
||||
}
|
||||
79
CLAUDE.md
79
CLAUDE.md
@@ -8,17 +8,18 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------------- | ------------------------------------------------------------- |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
||||
| HTTP Framework | Fastify 5.8.2 |
|
||||
| ORM | Prisma 6.19.2 → MySQL |
|
||||
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → 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 + Material UI v7 (Emotion) |
|
||||
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) |
|
||||
| Testing | Vitest 4.1.0 + Supertest |
|
||||
| PDF | Puppeteer 24.x |
|
||||
| Email | nodemailer 8.x |
|
||||
| Cron | node-cron 4.x |
|
||||
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,21 +36,21 @@ src/
|
||||
├── 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 + Material UI frontend (~105 .tsx files)
|
||||
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
|
||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
||||
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
|
||||
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
|
||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
|
||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
|
||||
│ ├── pages/ # One file per page/feature
|
||||
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
|
||||
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
|
||||
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 49 models, MySQL, snake_case columns
|
||||
├── schema.prisma # 52 models, MySQL, snake_case columns
|
||||
└── migrations/ # Applied migrations
|
||||
|
||||
dist/ # Compiled server (CommonJS, ES2022)
|
||||
@@ -75,9 +76,14 @@ npm run build:client # vite build → dist-client/
|
||||
npm start # node dist/server.js
|
||||
|
||||
# Tests
|
||||
npm test # vitest run (single pass)
|
||||
npm test # vitest run (single pass) — runs against the app_test DB via .env.test
|
||||
npm run test:watch # vitest watch
|
||||
|
||||
# Quality gates
|
||||
npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
|
||||
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
|
||||
npm run format # prettier --write .
|
||||
|
||||
# Database
|
||||
npx prisma migrate dev # Create migration from schema changes + apply to dev
|
||||
npx prisma migrate dev --name <descriptive_name> # Named migration
|
||||
@@ -238,11 +244,13 @@ if ("error" in body) return error(reply, body.error, 400);
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
|
||||
|
||||
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
|
||||
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy` → `DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`.
|
||||
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
|
||||
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
|
||||
- **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`.
|
||||
@@ -301,7 +309,20 @@ The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 line
|
||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
||||
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
|
||||
|
||||
---
|
||||
|
||||
## AI Assistant — "Odin" (shipped v2.1.5)
|
||||
|
||||
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase.
|
||||
|
||||
- **SDK / model:** `@anthropic-ai/sdk` → `claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`).
|
||||
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
|
||||
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
|
||||
- **Frontend:** `src/admin/pages/Odin.tsx` → `src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces`→`Newsreader` font added to `index.html`.
|
||||
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
|
||||
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -431,6 +452,40 @@ audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-
|
||||
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
|
||||
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
|
||||
|
||||
## Conventions (enforced — added by the 2026-06-09 full audit)
|
||||
|
||||
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
|
||||
|
||||
**Zod validation — shared coercion helpers are MANDATORY**
|
||||
|
||||
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
|
||||
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0–100`, month `1–12`) → `numberInRange`.
|
||||
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
|
||||
- An optional email a form may submit as `""` → **`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
|
||||
|
||||
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
|
||||
|
||||
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
|
||||
|
||||
**Concurrency — locking discipline for stock / balance / sequence mutations**
|
||||
|
||||
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
|
||||
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
|
||||
|
||||
**Permissions — guard reads, not just writes**
|
||||
|
||||
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
|
||||
|
||||
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
|
||||
|
||||
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error** — `npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
|
||||
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
|
||||
|
||||
**Safety**
|
||||
|
||||
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
|
||||
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Gotchas
|
||||
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# boha-app-ts
|
||||
|
||||
Internal business-management system for a Czech company (attendance, invoicing,
|
||||
leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite
|
||||
of the legacy PHP app. User-facing text is Czech by design.
|
||||
|
||||
> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA
|
||||
- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an
|
||||
SPA in `src/admin/`, served by the same Fastify process (Vite middleware in
|
||||
dev, static files in prod)
|
||||
- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the
|
||||
admins-only "Odin" assistant)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (LTS) and a MySQL database
|
||||
- Copy `.env.example` → `.env` and fill in the **required** vars (`DATABASE_URL`,
|
||||
`JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma generate
|
||||
npx prisma migrate dev # apply migrations to your dev DB
|
||||
npx prisma db seed # optional: dev-only sample data (NEVER on prod)
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm run dev:server # API (tsx watch) on http://127.0.0.1:3050
|
||||
npm run dev:client # Vite dev server (manage separately)
|
||||
```
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
npm run build # tsc server → dist/ + vite client → dist-client/
|
||||
npm start # node dist/server.js
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks
|
||||
```
|
||||
|
||||
## Quality gates
|
||||
|
||||
Before committing, all of these must pass:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`)
|
||||
npm run build
|
||||
npx vitest run
|
||||
npm run lint # ESLint (incl. react-hooks) — see eslint.config.js
|
||||
```
|
||||
|
||||
## Database migrations
|
||||
|
||||
Every schema/data change is a tracked Prisma migration — **never** `prisma db push`
|
||||
or raw SQL on production. Stop the dev server before running `prisma migrate dev`.
|
||||
See CLAUDE.md → _Database Migrations_ for the full workflow and the production
|
||||
deploy/hotfix process.
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
386
REVIEW.md
Normal file
386
REVIEW.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Codebase Audit — REVIEW.md
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Scope:** entire repository (tracked source/config files via `git ls-files`)
|
||||
**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction.
|
||||
|
||||
**Total files to review:** 277
|
||||
|
||||
---
|
||||
|
||||
|
||||
## .claude/hooks/
|
||||
|
||||
- [x] .claude/hooks/block-env.js
|
||||
- [x] .claude/hooks/format-on-save.js
|
||||
|
||||
## .claude/
|
||||
|
||||
- [x] .claude/settings.json
|
||||
- [x] .claude/settings.local.json
|
||||
|
||||
## ./
|
||||
|
||||
- [x] .env.example
|
||||
- [x] CLAUDE.md
|
||||
- [x] boneyard.config.json
|
||||
- [x] index.html
|
||||
- [x] package.json
|
||||
|
||||
## prisma/
|
||||
|
||||
- [x] prisma/schema.prisma
|
||||
- [x] prisma/seed.ts
|
||||
|
||||
## scripts/
|
||||
|
||||
- [x] scripts/migrate-received-invoices-to-nas.ts
|
||||
- [x] scripts/rotate-totp-key.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/App.tsx
|
||||
|
||||
## src/__tests__/
|
||||
|
||||
- [x] src/__tests__/ai.test.ts
|
||||
- [x] src/__tests__/auth.service.test.ts
|
||||
- [x] src/__tests__/auth.test.ts
|
||||
- [x] src/__tests__/customers.schema.test.ts
|
||||
- [x] src/__tests__/env.test.ts
|
||||
- [x] src/__tests__/exchange-rates.test.ts
|
||||
- [x] src/__tests__/helpers.ts
|
||||
- [x] src/__tests__/invoices.service.test.ts
|
||||
- [x] src/__tests__/manual-create.test.ts
|
||||
- [x] src/__tests__/nas-file-manager.test.ts
|
||||
- [x] src/__tests__/nas-project-folder.test.ts
|
||||
- [x] src/__tests__/numbering.test.ts
|
||||
- [x] src/__tests__/plan.test.ts
|
||||
- [x] src/__tests__/planAuditDescription.test.ts
|
||||
- [x] src/__tests__/planCategory.test.ts
|
||||
- [x] src/__tests__/received-invoices-vat.test.ts
|
||||
- [x] src/__tests__/schema-nan.test.ts
|
||||
- [x] src/__tests__/setup.ts
|
||||
- [x] src/__tests__/warehouse.test.ts
|
||||
|
||||
## src/admin/
|
||||
|
||||
- [x] src/admin/AdminApp.tsx
|
||||
- [x] src/admin/GlobalStyles.tsx
|
||||
|
||||
## src/admin/components/
|
||||
|
||||
- [x] src/admin/components/AlertContainer.tsx
|
||||
- [x] src/admin/components/AttendanceShiftTable.tsx
|
||||
- [x] src/admin/components/BulkAttendanceModal.tsx
|
||||
- [x] src/admin/components/BulkPlanModal.tsx
|
||||
- [x] src/admin/components/ErrorBoundary.tsx
|
||||
- [x] src/admin/components/Forbidden.tsx
|
||||
- [x] src/admin/components/OrderConfirmationModal.tsx
|
||||
- [x] src/admin/components/PlanCategoriesModal.tsx
|
||||
- [x] src/admin/components/PlanCellModal.tsx
|
||||
- [x] src/admin/components/PlanGrid.tsx
|
||||
- [x] src/admin/components/PlanRangeChips.tsx
|
||||
- [x] src/admin/components/ProjectFileManager.tsx
|
||||
- [x] src/admin/components/RichEditor.tsx
|
||||
- [x] src/admin/components/ShiftFormModal.tsx
|
||||
- [x] src/admin/components/ShortcutsHelp.tsx
|
||||
|
||||
## src/admin/components/dashboard/
|
||||
|
||||
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
|
||||
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
|
||||
- [x] src/admin/components/dashboard/DashKpiCards.tsx
|
||||
- [x] src/admin/components/dashboard/DashProfile.tsx
|
||||
- [x] src/admin/components/dashboard/DashQuickActions.tsx
|
||||
- [x] src/admin/components/dashboard/DashSessions.tsx
|
||||
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
|
||||
|
||||
## src/admin/components/odin/
|
||||
|
||||
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
|
||||
- [x] src/admin/components/odin/OdinChat.tsx
|
||||
- [x] src/admin/components/odin/OdinComposer.tsx
|
||||
- [x] src/admin/components/odin/OdinMark.tsx
|
||||
- [x] src/admin/components/odin/OdinSidebar.tsx
|
||||
- [x] src/admin/components/odin/OdinThread.tsx
|
||||
- [x] src/admin/components/odin/types.ts
|
||||
|
||||
## src/admin/components/warehouse/
|
||||
|
||||
- [x] src/admin/components/warehouse/ItemPicker.tsx
|
||||
- [x] src/admin/components/warehouse/ReservationPicker.tsx
|
||||
|
||||
## src/admin/context/
|
||||
|
||||
- [x] src/admin/context/AlertContext.tsx
|
||||
- [x] src/admin/context/AuthContext.tsx
|
||||
|
||||
## src/admin/hooks/
|
||||
|
||||
- [x] src/admin/hooks/useAttendanceAdmin.ts
|
||||
- [x] src/admin/hooks/useDebounce.ts
|
||||
- [x] src/admin/hooks/useModalLock.ts
|
||||
- [x] src/admin/hooks/usePaginatedQuery.ts
|
||||
- [x] src/admin/hooks/usePlanWork.ts
|
||||
- [x] src/admin/hooks/useReducedMotion.ts
|
||||
- [x] src/admin/hooks/useTableSort.ts
|
||||
|
||||
## src/admin/lib/
|
||||
|
||||
- [x] src/admin/lib/apiAdapter.ts
|
||||
- [x] src/admin/lib/entityTypeLabels.ts
|
||||
|
||||
## src/admin/lib/queries/
|
||||
|
||||
- [x] src/admin/lib/queries/ai.ts
|
||||
- [x] src/admin/lib/queries/attendance.ts
|
||||
- [x] src/admin/lib/queries/auditLog.ts
|
||||
- [x] src/admin/lib/queries/common.ts
|
||||
- [x] src/admin/lib/queries/dashboard.ts
|
||||
- [x] src/admin/lib/queries/invoices.ts
|
||||
- [x] src/admin/lib/queries/leave.ts
|
||||
- [x] src/admin/lib/queries/mutations.ts
|
||||
- [x] src/admin/lib/queries/offers.ts
|
||||
- [x] src/admin/lib/queries/orders.ts
|
||||
- [x] src/admin/lib/queries/plan.ts
|
||||
- [x] src/admin/lib/queries/projects.ts
|
||||
- [x] src/admin/lib/queries/settings.ts
|
||||
- [x] src/admin/lib/queries/trips.ts
|
||||
- [x] src/admin/lib/queries/users.ts
|
||||
- [x] src/admin/lib/queries/vehicles.ts
|
||||
- [x] src/admin/lib/queries/warehouse.ts
|
||||
|
||||
## src/admin/lib/
|
||||
|
||||
- [x] src/admin/lib/queryClient.ts
|
||||
|
||||
## src/admin/pages/
|
||||
|
||||
- [x] src/admin/pages/Attendance.tsx
|
||||
- [x] src/admin/pages/AttendanceAdmin.tsx
|
||||
- [x] src/admin/pages/AttendanceBalances.tsx
|
||||
- [x] src/admin/pages/AttendanceCreate.tsx
|
||||
- [x] src/admin/pages/AttendanceHistory.tsx
|
||||
- [x] src/admin/pages/AttendanceLocation.tsx
|
||||
- [x] src/admin/pages/AuditLog.tsx
|
||||
- [x] src/admin/pages/CompanySettings.tsx
|
||||
- [x] src/admin/pages/Dashboard.tsx
|
||||
- [x] src/admin/pages/InvoiceDetail.tsx
|
||||
- [x] src/admin/pages/Invoices.tsx
|
||||
- [x] src/admin/pages/LeaveApproval.tsx
|
||||
- [x] src/admin/pages/LeaveRequests.tsx
|
||||
- [x] src/admin/pages/Login.tsx
|
||||
- [x] src/admin/pages/NotFound.tsx
|
||||
- [x] src/admin/pages/Odin.tsx
|
||||
- [x] src/admin/pages/OfferDetail.tsx
|
||||
- [x] src/admin/pages/Offers.tsx
|
||||
- [x] src/admin/pages/OffersCustomers.tsx
|
||||
- [x] src/admin/pages/OffersTemplates.tsx
|
||||
- [x] src/admin/pages/OrderDetail.tsx
|
||||
- [x] src/admin/pages/Orders.tsx
|
||||
- [x] src/admin/pages/PlanWork.tsx
|
||||
- [x] src/admin/pages/ProjectDetail.tsx
|
||||
- [x] src/admin/pages/Projects.tsx
|
||||
- [x] src/admin/pages/ReceivedInvoices.tsx
|
||||
- [x] src/admin/pages/Settings.tsx
|
||||
- [x] src/admin/pages/Trips.tsx
|
||||
- [x] src/admin/pages/TripsAdmin.tsx
|
||||
- [x] src/admin/pages/TripsHistory.tsx
|
||||
- [x] src/admin/pages/UiKit.tsx
|
||||
- [x] src/admin/pages/Users.tsx
|
||||
- [x] src/admin/pages/Vehicles.tsx
|
||||
- [x] src/admin/pages/Warehouse.tsx
|
||||
- [x] src/admin/pages/WarehouseCategories.tsx
|
||||
- [x] src/admin/pages/WarehouseInventory.tsx
|
||||
- [x] src/admin/pages/WarehouseInventoryDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseInventoryForm.tsx
|
||||
- [x] src/admin/pages/WarehouseIssueDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseIssueForm.tsx
|
||||
- [x] src/admin/pages/WarehouseIssues.tsx
|
||||
- [x] src/admin/pages/WarehouseItemDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseItems.tsx
|
||||
- [x] src/admin/pages/WarehouseLocations.tsx
|
||||
- [x] src/admin/pages/WarehouseReceiptDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseReceiptForm.tsx
|
||||
- [x] src/admin/pages/WarehouseReceipts.tsx
|
||||
- [x] src/admin/pages/WarehouseReports.tsx
|
||||
- [x] src/admin/pages/WarehouseReservations.tsx
|
||||
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
||||
|
||||
## src/admin/
|
||||
|
||||
- [x] src/admin/theme.test.ts
|
||||
- [x] src/admin/theme.ts
|
||||
|
||||
## src/admin/ui/
|
||||
|
||||
- [x] src/admin/ui/Alert.tsx
|
||||
- [x] src/admin/ui/AppShell.tsx
|
||||
- [x] src/admin/ui/Button.tsx
|
||||
- [x] src/admin/ui/Card.tsx
|
||||
- [x] src/admin/ui/Checkbox.tsx
|
||||
- [x] src/admin/ui/ConfirmDialog.tsx
|
||||
- [x] src/admin/ui/DataTable.tsx
|
||||
- [x] src/admin/ui/DateField.tsx
|
||||
- [x] src/admin/ui/EmptyState.tsx
|
||||
- [x] src/admin/ui/Field.tsx
|
||||
- [x] src/admin/ui/FileUpload.tsx
|
||||
- [x] src/admin/ui/FilterBar.tsx
|
||||
- [x] src/admin/ui/LoadingState.tsx
|
||||
- [x] src/admin/ui/Modal.tsx
|
||||
- [x] src/admin/ui/MonthField.tsx
|
||||
- [x] src/admin/ui/MuiProvider.tsx
|
||||
- [x] src/admin/ui/PageEnter.tsx
|
||||
- [x] src/admin/ui/PageHeader.tsx
|
||||
- [x] src/admin/ui/Pagination.tsx
|
||||
- [x] src/admin/ui/ProgressBar.tsx
|
||||
- [x] src/admin/ui/RichText.tsx
|
||||
- [x] src/admin/ui/Select.tsx
|
||||
- [x] src/admin/ui/SidebarNav.tsx
|
||||
- [x] src/admin/ui/StatCard.tsx
|
||||
- [x] src/admin/ui/StatusChip.tsx
|
||||
- [x] src/admin/ui/Tabs.tsx
|
||||
- [x] src/admin/ui/TextField.tsx
|
||||
- [x] src/admin/ui/ThemeToggle.tsx
|
||||
- [x] src/admin/ui/TimeField.tsx
|
||||
- [x] src/admin/ui/index.ts
|
||||
- [x] src/admin/ui/navData.tsx
|
||||
- [x] src/admin/ui/useDialogScrollLock.ts
|
||||
|
||||
## src/admin/utils/
|
||||
|
||||
- [x] src/admin/utils/api.ts
|
||||
- [x] src/admin/utils/attendanceHelpers.ts
|
||||
- [x] src/admin/utils/dashboardHelpers.ts
|
||||
- [x] src/admin/utils/formatters.ts
|
||||
|
||||
## src/config/
|
||||
|
||||
- [x] src/config/database.ts
|
||||
- [x] src/config/env.ts
|
||||
|
||||
## src/context/
|
||||
|
||||
- [x] src/context/ThemeContext.tsx
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/main.tsx
|
||||
|
||||
## src/middleware/
|
||||
|
||||
- [x] src/middleware/auth.ts
|
||||
- [x] src/middleware/security.ts
|
||||
|
||||
## src/routes/admin/
|
||||
|
||||
- [x] src/routes/admin/ai.ts
|
||||
- [x] src/routes/admin/attendance.ts
|
||||
- [x] src/routes/admin/audit-log.ts
|
||||
- [x] src/routes/admin/auth.ts
|
||||
- [x] src/routes/admin/bank-accounts.ts
|
||||
- [x] src/routes/admin/company-settings.ts
|
||||
- [x] src/routes/admin/customers.ts
|
||||
- [x] src/routes/admin/dashboard.ts
|
||||
- [x] src/routes/admin/invoices-pdf.ts
|
||||
- [x] src/routes/admin/invoices.ts
|
||||
- [x] src/routes/admin/leave-requests.ts
|
||||
- [x] src/routes/admin/offers-pdf.ts
|
||||
- [x] src/routes/admin/orders-pdf.ts
|
||||
- [x] src/routes/admin/orders.ts
|
||||
- [x] src/routes/admin/plan.ts
|
||||
- [x] src/routes/admin/profile.ts
|
||||
- [x] src/routes/admin/project-files.ts
|
||||
- [x] src/routes/admin/projects.ts
|
||||
- [x] src/routes/admin/quotations.ts
|
||||
- [x] src/routes/admin/received-invoices.ts
|
||||
- [x] src/routes/admin/roles.ts
|
||||
- [x] src/routes/admin/scope-templates.ts
|
||||
- [x] src/routes/admin/sessions.ts
|
||||
- [x] src/routes/admin/totp.ts
|
||||
- [x] src/routes/admin/trips.ts
|
||||
- [x] src/routes/admin/users.ts
|
||||
- [x] src/routes/admin/vehicles.ts
|
||||
- [x] src/routes/admin/warehouse.ts
|
||||
|
||||
## src/schemas/
|
||||
|
||||
- [x] src/schemas/ai.schema.ts
|
||||
- [x] src/schemas/attendance.schema.ts
|
||||
- [x] src/schemas/auth.schema.ts
|
||||
- [x] src/schemas/bank-accounts.schema.ts
|
||||
- [x] src/schemas/common.ts
|
||||
- [x] src/schemas/customers.schema.ts
|
||||
- [x] src/schemas/invoices.schema.ts
|
||||
- [x] src/schemas/leave-requests.schema.ts
|
||||
- [x] src/schemas/offers.schema.ts
|
||||
- [x] src/schemas/orders.schema.ts
|
||||
- [x] src/schemas/plan.schema.ts
|
||||
- [x] src/schemas/planCategory.schema.ts
|
||||
- [x] src/schemas/profile.schema.ts
|
||||
- [x] src/schemas/projects.schema.ts
|
||||
- [x] src/schemas/received-invoices.schema.ts
|
||||
- [x] src/schemas/roles.schema.ts
|
||||
- [x] src/schemas/scope-templates.schema.ts
|
||||
- [x] src/schemas/settings.schema.ts
|
||||
- [x] src/schemas/trips.schema.ts
|
||||
- [x] src/schemas/users.schema.ts
|
||||
- [x] src/schemas/vehicles.schema.ts
|
||||
- [x] src/schemas/warehouse.schema.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/server.ts
|
||||
|
||||
## src/services/
|
||||
|
||||
- [x] src/services/ai.service.ts
|
||||
- [x] src/services/attendance.service.ts
|
||||
- [x] src/services/audit.ts
|
||||
- [x] src/services/auth.ts
|
||||
- [x] src/services/exchange-rates.ts
|
||||
- [x] src/services/invoice-alerts.ts
|
||||
- [x] src/services/invoices.service.ts
|
||||
- [x] src/services/leave-notification.ts
|
||||
- [x] src/services/mailer.ts
|
||||
- [x] src/services/nas-file-manager.ts
|
||||
- [x] src/services/nas-financials-manager.ts
|
||||
- [x] src/services/nas-offers-manager.ts
|
||||
- [x] src/services/numbering.service.ts
|
||||
- [x] src/services/offers.service.ts
|
||||
- [x] src/services/orders.service.ts
|
||||
- [x] src/services/plan.service.ts
|
||||
- [x] src/services/planCategory.service.ts
|
||||
- [x] src/services/projects.service.ts
|
||||
- [x] src/services/system-settings.ts
|
||||
- [x] src/services/users.service.ts
|
||||
- [x] src/services/warehouse.service.ts
|
||||
|
||||
## src/types/
|
||||
|
||||
- [x] src/types/fastify.d.ts
|
||||
- [x] src/types/index.ts
|
||||
|
||||
## src/utils/
|
||||
|
||||
- [x] src/utils/czech-holidays.ts
|
||||
- [x] src/utils/date.ts
|
||||
- [x] src/utils/encryption.ts
|
||||
- [x] src/utils/html-to-pdf.ts
|
||||
- [x] src/utils/pagination.ts
|
||||
- [x] src/utils/planAuditDescription.ts
|
||||
- [x] src/utils/response.ts
|
||||
- [x] src/utils/totp.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/vite-env.d.ts
|
||||
|
||||
## ./
|
||||
|
||||
- [x] tsconfig.app.json
|
||||
- [x] tsconfig.json
|
||||
- [x] tsconfig.server.json
|
||||
- [x] vite.config.ts
|
||||
- [x] vitest.config.ts
|
||||
1330
REVIEW_FINDINGS.md
Normal file
1330
REVIEW_FINDINGS.md
Normal file
File diff suppressed because it is too large
Load Diff
1385
REVIEW_FIXES.md
Normal file
1385
REVIEW_FIXES.md
Normal file
File diff suppressed because it is too large
Load Diff
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Cross-Module Consistency Report: Offers, Invoices, Orders
|
||||
|
||||
> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
|
||||
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
|
||||
> Examination only — no code was changed. File:line refs are at the time of the audit.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.
|
||||
|
||||
## 2. Recommended Canonical Patterns
|
||||
|
||||
| Dimension | Standardize on | Why |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| List KPI cards | **Invoices** (`Invoices.tsx:420-505`) | Only module with a 4-card metrics grid; gives business context Orders/Offers lack |
|
||||
| List load/skeleton | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784) | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank |
|
||||
| List row state tinting | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx` | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
|
||||
| List status filter | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders) | Tabs show all options inline; Select scales — choose by option count |
|
||||
| Search-aware empty state | **Offers** (`Offers.tsx:840-855`) | Distinguishes search-empty (no CTA) from list-empty (with CTA) |
|
||||
| Detail read-only mode | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`) | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced |
|
||||
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`) | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct) |
|
||||
| Save-button loading | **Invoice/Order** (CircularProgress + text) | Clearer visual signal than Offers' text-only swap |
|
||||
| Detail guard placement | **OfferDetail** (all guards top-level, `:1087-1091`) | Flattest; no `if (!dataReady)` buried inside render branches |
|
||||
| Customer/supplier picker | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`) | Keyboard-accessible, searchable, Material-standard |
|
||||
| Boolean fields | **MUI `CheckboxField` kit** (as in OrdersReceived modal) | Themed, accessible; replaces raw `<input type=checkbox>` |
|
||||
| Notes fields | **RichEditor** (Offers/Orders already) | Uniform editing; future-proof for formatting |
|
||||
| `issued_by` | **Read-only, auto-filled** (Invoices, `:1881-1885`) | Immutable provenance tied to the issuing user |
|
||||
| Currency options | **Company settings** (Offers/Invoices) | Admin-configurable without code change |
|
||||
| Line-item labels/widths | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier |
|
||||
| Amount column header | **"Celkem"** (all but ReceivedInvoices' "Částka") | Single label for the same concept |
|
||||
|
||||
## 3. Inconsistencies by Dimension
|
||||
|
||||
### A. Tables & Lists
|
||||
|
||||
**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.
|
||||
|
||||
**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.
|
||||
|
||||
**MED — Orders missing list features that Invoices/Offers have.**
|
||||
|
||||
- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
|
||||
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
|
||||
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.
|
||||
|
||||
**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.
|
||||
|
||||
**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.
|
||||
|
||||
**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.
|
||||
|
||||
**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.
|
||||
|
||||
_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._
|
||||
|
||||
### B. Detail Buttons & Layout
|
||||
|
||||
**HIGH — Delete-button safety bugs + Orders feature gap.**
|
||||
|
||||
- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
|
||||
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
|
||||
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
|
||||
Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.
|
||||
|
||||
**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.
|
||||
|
||||
**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.
|
||||
|
||||
**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.
|
||||
|
||||
**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.
|
||||
|
||||
**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.
|
||||
|
||||
**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.
|
||||
|
||||
_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._
|
||||
|
||||
### C. Forms & Fields
|
||||
|
||||
**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.
|
||||
|
||||
**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.
|
||||
|
||||
**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.
|
||||
|
||||
**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.
|
||||
|
||||
**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.
|
||||
|
||||
**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.
|
||||
|
||||
_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._
|
||||
|
||||
### D. Line-Item Editors
|
||||
|
||||
Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).
|
||||
|
||||
**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
|
||||
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem` → `align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
|
||||
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
|
||||
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
|
||||
**LOW — Remove-button cell width.** Offers `3rem` → `2.5rem`. Effort: **S**.
|
||||
|
||||
_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._
|
||||
|
||||
## 4. Suggested Fix Order
|
||||
|
||||
**Batch 1 — Mechanical quick wins (low judgment, low risk).**
|
||||
|
||||
1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem`→`5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem`→`2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right`→`center`; "%DPH"→"DPH").
|
||||
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
|
||||
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
|
||||
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.
|
||||
|
||||
**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.
|
||||
|
||||
**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.
|
||||
|
||||
**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).
|
||||
|
||||
## 5. Already Consistent — Do Not Touch
|
||||
|
||||
- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
|
||||
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
|
||||
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
|
||||
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
|
||||
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.
|
||||
|
||||
**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Status/State Models + Draft Persistence (follow-up audit)
|
||||
|
||||
> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).
|
||||
|
||||
## Summary
|
||||
|
||||
The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.
|
||||
|
||||
## State-model comparison
|
||||
|
||||
| Document | Storage | Key language | #States | Transitions | Auto-transitions | Edit-lock | Color consistency |
|
||||
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
||||
| Offer (quotations) | `String(30)` def `active` | mixed (`active`/`ordered`/`invalidated`) | 3 | none (manual; `invalidateOffer`) | none | locked only when `invalidated` | **no label/color map** (tabs only) |
|
||||
| Customer Order (orders) | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4 | `VALID_TRANSITIONS` | → project status sync | items lock in `dokoncena`/`stornovana` | consistent list↔detail |
|
||||
| Issued Order (issued_orders) | **enum** def `draft` | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5 | `VALID_TRANSITIONS` | none | full lock outside `draft`/`sent` | consistent |
|
||||
| Issued Invoice (invoices) | `String(30)` def `issued` | English (`issued`/`overdue`/`paid`) | 3 | `VALID_TRANSITIONS` | `markOverdueInvoices()` + auto paid_date | full lock in `paid` | **DRIFTED**: `issued`=warning in list, =info in detail |
|
||||
| Received Invoice (received_invoices) | **enum** def `unpaid` | English (`unpaid`/`paid`) | 2 | implicit (no revert) | auto paid_date, month/year | locks in `paid` | consistent (list only) |
|
||||
|
||||
## Intentional (KEEP) vs Accidental (FIX)
|
||||
|
||||
**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.
|
||||
|
||||
**Accidental — fix:**
|
||||
|
||||
- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
|
||||
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
|
||||
- Divergent semantic color for the same status (`issued` = warning vs info).
|
||||
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
|
||||
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
|
||||
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
|
||||
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
|
||||
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
|
||||
- Arbitrary draft-banner visibility heuristics.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata`→`received`, `v_realizaci`→`in_progress`, `dokoncena`→`completed`, `stornovana`→`cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
|
||||
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
|
||||
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
|
||||
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
|
||||
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**
|
||||
|
||||
## Effort + risk
|
||||
|
||||
- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
|
||||
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
|
||||
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.
|
||||
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
File diff suppressed because it is too large
Load Diff
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Objednávky vydané (Issued Purchase Orders) — Design Spec
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorm) → ready for implementation plan
|
||||
**Author:** brainstorm session
|
||||
|
||||
> **Updated 2026-06-09 (as-built):** Shipped with three deviations from this
|
||||
> brainstorm — **Vydané-first tabs** (Vydané | Přijaté, mirroring Invoices), a
|
||||
> **shared chevron month/year filter** above the tabs that filters both lists,
|
||||
> and a PDF **rebuilt on the shared invoice/order-confirmation template** rather
|
||||
> than the custom layout sketched below. See the affected sections (UI placement,
|
||||
> PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new document type — **objednávky vydané** (purchase orders you issue to a
|
||||
supplier) — alongside the existing customer orders. You author the PO with line
|
||||
items, it gets an auto-generated number, you track its status, and you export it
|
||||
to PDF to send to the supplier. The existing orders module becomes the
|
||||
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
|
||||
page already splits **Vydané | Přijaté**.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
|
||||
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
|
||||
procurement loop (ordered → received → invoiced) is a deliberate later phase.
|
||||
- **No new supplier master.** POs reuse the existing `customers` table as generic
|
||||
business partners.
|
||||
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
|
||||
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
|
||||
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
|
||||
received-document concept and does not apply to documents you create.
|
||||
|
||||
---
|
||||
|
||||
## Decisions log (resolved during brainstorm)
|
||||
|
||||
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
|
||||
quotation → order → project → invoice) stay as the **Přijaté** side, largely
|
||||
unchanged — only surfaced under a new tab. The new **Vydané** side is a
|
||||
brand-new document type. _(Not a `direction` flag on the existing table.)_
|
||||
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
|
||||
used as a generic business partner. No new supplier/vendor master.
|
||||
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
|
||||
received invoices.
|
||||
4. **UI placement.** **Tabs on the Orders page**, mirroring the Invoices page.
|
||||
_(As-built: **Vydané first / Přijaté second** — `Vydané | Přijaté`, tab values
|
||||
`vydane`/`prijate`, persisted in `?tab=` — matching the Invoices page order. A
|
||||
shared **chevron month/year selector** sits above the tabs and filters BOTH
|
||||
lists: Vydané by `order_date`, Přijaté by `created_at`. The pre-existing Přijaté
|
||||
search bug — search term sent but ignored by the backend — was also fixed.)_
|
||||
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
|
||||
mirroring `invoices`/`invoice_items` — _not_ an extension of `orders` and _not_
|
||||
an upload-only record like `received_invoices`.
|
||||
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
|
||||
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
|
||||
use the same per-line-rounded math as `computeInvoiceTotals`.
|
||||
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
|
||||
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
|
||||
assigned **at creation** (even for a `draft`), with release-on-delete.
|
||||
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
|
||||
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
|
||||
permission set across both tabs; **no permission migration needed**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
A self-contained vertical slice following the codebase's established document
|
||||
pattern (the same shape `invoices` uses):
|
||||
|
||||
```
|
||||
prisma/schema.prisma → models issued_orders, issued_order_items + enum
|
||||
src/schemas/issued-orders.schema.ts
|
||||
src/services/issued-orders.service.ts
|
||||
src/services/numbering.service.ts (extend: issued-order number fns)
|
||||
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
|
||||
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
|
||||
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
|
||||
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
|
||||
src/admin/lib/queries/issued-orders.ts
|
||||
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
|
||||
src/admin/AdminApp.tsx (lazy routes for detail/new)
|
||||
src/__tests__/issued-orders.test.ts
|
||||
```
|
||||
|
||||
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
|
||||
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
|
||||
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
|
||||
invalidation, deterministic ordering with an `id` tiebreak, locked
|
||||
read-modify-write for numbering).
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `issued_orders` (parallels `invoices`)
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
|
||||
| `id` | Int PK autoincrement | |
|
||||
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
|
||||
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
|
||||
| `status` | enum `issued_orders_status` | default `draft` |
|
||||
| `currency` | VARCHAR(10) default `"CZK"` | |
|
||||
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
|
||||
| `apply_vat` | Boolean default `true` | |
|
||||
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
|
||||
| `order_date` | Date | date the PO is issued |
|
||||
| `delivery_date` | Date nullable | requested delivery / required-by |
|
||||
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
|
||||
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||
| `issued_by` | VARCHAR(255) nullable | signatory name |
|
||||
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
|
||||
| `internal_notes` | Text nullable | private, **not** on PDF |
|
||||
| `created_at` | DateTime default now | |
|
||||
| `modified_at` | DateTime @updatedAt | |
|
||||
|
||||
**Relations:** `issued_order_items[]`, `customers?`.
|
||||
**Indexes:** `(customer_id)`, `(status, order_date)`.
|
||||
|
||||
### `issued_order_items` (parallels `invoice_items`)
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------------ | ------------------------------------------- | ------------------------------- |
|
||||
| `id` | Int PK | |
|
||||
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
|
||||
| `description` | VARCHAR(500) | main line text |
|
||||
| `item_description` | Text nullable | optional long detail |
|
||||
| `quantity` | Decimal(12,3) default `1.000` | |
|
||||
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
|
||||
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
|
||||
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
|
||||
| `position` | Int default `0` | ordering |
|
||||
|
||||
**Index:** `(issued_order_id)`.
|
||||
|
||||
### `issued_orders_status` enum
|
||||
|
||||
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
|
||||
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
|
||||
|
||||
---
|
||||
|
||||
## Numbering
|
||||
|
||||
A dedicated sequence, exactly like invoices:
|
||||
|
||||
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
|
||||
as the table already enforces). Separate from `"shared"` and `"invoice"`.
|
||||
- New `company_settings` columns:
|
||||
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
|
||||
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
|
||||
`81`; `72` is free and configurable).
|
||||
- New functions in `src/services/numbering.service.ts`, reusing the existing
|
||||
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
|
||||
first-of-year retry):
|
||||
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
|
||||
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
|
||||
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
|
||||
held the current highest number that year (gap prevention), mirroring
|
||||
`releaseInvoiceNumber`.
|
||||
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
|
||||
releases the number per the rule above.
|
||||
|
||||
---
|
||||
|
||||
## Backend API
|
||||
|
||||
### Schema — `src/schemas/issued-orders.schema.ts`
|
||||
|
||||
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
|
||||
built **only** from the shared coercers in `src/schemas/common.ts`:
|
||||
|
||||
- `customer_id` → `nullableIntIdFromForm`
|
||||
- `quantity` → `positiveNumberFromForm`; `unit_price` → `nonNegativeNumberFromForm`
|
||||
- `vat_rate` → `numberInRange(0, 100)`
|
||||
- `order_date` / `delivery_date` → `isoDateString` (lenient)
|
||||
- text fields → `z.string().max(...)`; optional email-like none here
|
||||
- Czech user-facing messages; English identifiers.
|
||||
|
||||
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
|
||||
banned (silent `NaN`).
|
||||
|
||||
### Service — `src/services/issued-orders.service.ts`
|
||||
|
||||
Plain exported async functions returning `{ data }` or `{ error, status }`:
|
||||
|
||||
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
|
||||
`search` (po_number / supplier name); whitelist sort
|
||||
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
|
||||
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
|
||||
- `getIssuedOrder(id)` — full detail with items, supplier name, and
|
||||
`valid_transitions`.
|
||||
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
|
||||
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
|
||||
- `updateIssuedOrder(id, body)` — validates the status transition against
|
||||
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
|
||||
is editable (`draft`/`sent`); blocks any `po_number` change.
|
||||
- `deleteIssuedOrder(id)` — delete (items cascade) then
|
||||
`releaseIssuedOrderNumber(year, po_number)`.
|
||||
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
|
||||
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
|
||||
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
|
||||
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
|
||||
Falls back line `vat_rate` → doc `vat_rate` → 21.
|
||||
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
|
||||
|
||||
`VALID_TRANSITIONS`:
|
||||
|
||||
```
|
||||
draft → [sent, cancelled]
|
||||
sent → [confirmed, cancelled]
|
||||
confirmed → [completed, cancelled]
|
||||
completed → [] // terminal
|
||||
cancelled → [] // terminal
|
||||
```
|
||||
|
||||
### Routes — `src/routes/admin/issued-orders.ts`
|
||||
|
||||
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
|
||||
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
|
||||
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
|
||||
with `oldValues`/`newValues`). Adding this entity type requires also registering
|
||||
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
|
||||
`EntityType` value), per the single-source-of-truth convention.
|
||||
|
||||
| Method · Path | Guard | Purpose |
|
||||
| ------------------------------------------ | --------------- | -------------------------- |
|
||||
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
|
||||
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
|
||||
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
|
||||
| POST `/api/admin/issued-orders` | `orders.create` | create |
|
||||
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
|
||||
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
|
||||
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
|
||||
|
||||
---
|
||||
|
||||
## PDF export
|
||||
|
||||
> **As-built (2026-06-09):** rather than the custom/compact layout this section
|
||||
> originally sketched, the issued-order PDF was **rebuilt on the shared
|
||||
> invoice / order-confirmation red-accent (#de3a3a) template** (`src/routes/admin/issued-orders-pdf.ts`),
|
||||
> titled **OBJEDNÁVKA**, for visual consistency with the rest of the document
|
||||
> suite. Because a PO is a buying document, the direction is **flipped vs an
|
||||
> invoice**: the selected **customer record renders as "Dodavatel" (supplier)**
|
||||
> and **your company as "Odběratel" (buyer)**. It is generated **on demand** and
|
||||
> streamed as `application/pdf` — **no NAS persistence**. Sanitization,
|
||||
> on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
|
||||
|
||||
New route + template `src/routes/admin/issued-orders-pdf.ts`, reusing
|
||||
`htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
|
||||
|
||||
- **Dodavatel (supplier) block** = the selected `customers` record (name,
|
||||
IČO/`company_id`, DIČ/`vat_id`, address). **Odběratel (buyer) block** = your
|
||||
company (`company_settings`: name, IČO, DIČ, address, logo as base64).
|
||||
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
|
||||
- `cs` / `en` strings selected by `language`. Czech date/currency via the
|
||||
existing formatters.
|
||||
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
|
||||
used by invoices/orders before going into the template (mandatory for any new
|
||||
rich-text-on-PDF field).
|
||||
- **v1 behavior:** generate on demand and stream `application/pdf` with
|
||||
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
|
||||
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
|
||||
isolated follow-up.)
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
- **`src/admin/pages/Orders.tsx` — add `Vydané | Přijaté` tabs** using the same
|
||||
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
|
||||
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
|
||||
and permission check preserved unchanged; only presentation is reorganized. The
|
||||
**Vydané** tab renders the new list.
|
||||
_(As-built: `Orders.tsx` became a **parent shell** that owns the `PageHeader` +
|
||||
the shared month/year selector + the tabs; `IssuedOrders.tsx` (Vydané) and
|
||||
`OrdersReceived.tsx` (Přijaté) are **content-only children** receiving `month`/`year`
|
||||
props. `OrdersReceived`'s create modal is **hoisted** to the shell via
|
||||
`createOpen`/`setCreateOpen`, the ReceivedInvoices pattern. **No KPI/stat cards**
|
||||
on the Orders landing — deliberate, since orders have no paid/overdue semantics.)_
|
||||
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
|
||||
status chip (color per status), `order_date`, total (currency), actions (open,
|
||||
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
|
||||
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
|
||||
vydanou" button gated on `orders.create`.
|
||||
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
|
||||
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
|
||||
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
|
||||
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
|
||||
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
|
||||
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
|
||||
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
|
||||
imports come from the `ui/` kit.
|
||||
- **Queries** `src/admin/lib/queries/issued-orders.ts` — `issuedOrderListOptions`,
|
||||
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
|
||||
invalidate the **broad `["issued-orders"]`** domain key.
|
||||
- **Rules of Hooks:** all hooks run before any early permission `return`
|
||||
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
|
||||
`react-hooks/rules-of-hooks` as an error.
|
||||
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Reuse the existing `orders.*` set for the Vydané tab:
|
||||
|
||||
- `orders.view` — list + detail + PDF view
|
||||
- `orders.create` — create + next-number preview
|
||||
- `orders.edit` — update
|
||||
- `orders.delete` — delete
|
||||
- `orders.export` — PDF export (already seeded)
|
||||
|
||||
This mirrors invoices (issued + received share one `invoices.*` set) and needs
|
||||
**no permission migration**. _(Future option, out of scope for v1: a dedicated
|
||||
`purchase-orders._` set via a seed + migration if purchasing must be locked down
|
||||
separately from sales orders.)\*
|
||||
|
||||
---
|
||||
|
||||
## Status lifecycle
|
||||
|
||||
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
|
||||
|
||||
- **Editable** (header + items): `draft`, `sent`.
|
||||
- **Read-only except a permitted status change**: `confirmed`, `completed`,
|
||||
`cancelled`.
|
||||
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
|
||||
page only renders buttons for valid next states.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
|
||||
`buildApp()` (no Prisma mocks):
|
||||
|
||||
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
|
||||
generated number matches the configured pattern.
|
||||
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
|
||||
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
|
||||
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
|
||||
- **Status transitions:** every valid transition succeeds; invalid transitions
|
||||
return the proper error; items become read-only after `confirmed`.
|
||||
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
|
||||
permission (not bare auth).
|
||||
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
A single `prisma migrate dev --name issued_orders`:
|
||||
|
||||
1. Creates `issued_orders` and `issued_order_items` tables + the
|
||||
`issued_orders_status` enum.
|
||||
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
|
||||
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
|
||||
|
||||
Per project rules: **ask the user to stop the dev server first**; commit both
|
||||
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
|
||||
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
|
||||
|
||||
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
|
||||
`npx vitest run`, `npm run lint` (0 errors).
|
||||
|
||||
---
|
||||
|
||||
## Future phases (explicitly out of scope for v1)
|
||||
|
||||
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
|
||||
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
|
||||
Odin pre-filling a received invoice from a PO.
|
||||
- **NAS persistence** of the generated PO PDF (`?save=1`).
|
||||
- **Dedicated `purchase-orders.*` permission set.**
|
||||
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.
|
||||
78
eslint.config.mjs
Normal file
78
eslint.config.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"dist-client/**",
|
||||
"node_modules/**",
|
||||
"prisma/migrations/**",
|
||||
"src/admin/bones/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"*.config.ts",
|
||||
".claude/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
// Server + shared code
|
||||
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node, ...globals.browser },
|
||||
},
|
||||
rules: {
|
||||
// TS already resolves identifiers — `no-undef` only false-positives on
|
||||
// global types/ambient names in .ts files.
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "warn",
|
||||
"no-empty": ["warn", { allowEmptyCatch: false }],
|
||||
// Intentional control-character regexes (NUL/path-traversal guards,
|
||||
// combining-marks stripping) — not a defect here.
|
||||
"no-control-regex": "off",
|
||||
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
|
||||
"no-useless-escape": "warn",
|
||||
"no-useless-assignment": "warn",
|
||||
"preserve-caught-error": "warn",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Frontend React code
|
||||
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
|
||||
files: ["scripts/**/*.{js,mjs,cjs}"],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
rules: { "no-undef": "off" },
|
||||
},
|
||||
{
|
||||
// Tests
|
||||
files: ["src/__tests__/**/*.ts"],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
2688
package-lock.json
generated
2688
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -18,10 +18,12 @@
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
"seed": "tsx prisma/seed.ts",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -42,7 +44,8 @@
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@mui/material": "^7.3.11",
|
||||
"@mui/x-date-pickers": "^8.29.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -50,43 +53,49 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"file-type": "^16.5.4",
|
||||
"file-type": "^22.0.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"mariadb": "^3.5.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"otpauth": "^9.5.0",
|
||||
"prisma": "^6.19.2",
|
||||
"prisma": "^7.8.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mysql": "^2.15.27",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
|
||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
// Prisma 7 moved the datasource connection URL and seed config out of
|
||||
// schema.prisma / package.json into this file. `import "dotenv/config"` is
|
||||
// required because Prisma 7 no longer auto-loads .env. The runtime database
|
||||
// connection itself is made via the driver adapter in src/config/database.ts.
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
migrations: {
|
||||
seed: "tsx prisma/seed.ts",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Audit schema hardening (from the 2026-06-09 codebase audit).
|
||||
--
|
||||
-- ⚠️ PENDING — NOT YET APPLIED. Review, then apply with the dev server stopped:
|
||||
-- npx prisma migrate deploy (prod / already-tracked)
|
||||
-- -- or, in dev, `npx prisma migrate dev` will pick it up as pending.
|
||||
--
|
||||
-- What this does:
|
||||
-- • Warehouse line→header relations now CASCADE on delete (deleting a
|
||||
-- receipt/issue/inventory session removes its lines/attachments).
|
||||
-- • Warehouse line→master-data (item/batch/location) and the financial FKs
|
||||
-- (invoices/orders/projects/quotations → customer/order/quotation) are now
|
||||
-- explicit ON DELETE RESTRICT — preventing deletion of a referenced master
|
||||
-- record / orphaning of a financial record. (Some of these were previously
|
||||
-- the optional-relation default SET NULL; RESTRICT is the safer intent.)
|
||||
-- • UNIQUE on warehouse document numbers (receipt_number/issue_number/
|
||||
-- session_number) — like every other document-number column.
|
||||
-- • New indexes on hot FK filters (sklad_issues/receipts, bank_accounts).
|
||||
--
|
||||
-- ⚠️ BEFORE APPLYING ON PRODUCTION, verify existing data won't violate the new
|
||||
-- constraints: no DUPLICATE non-null receipt/issue/session numbers (else the
|
||||
-- UNIQUE index fails), and no rows that would break the RESTRICT FKs. Test on
|
||||
-- a copy first.
|
||||
--
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_3`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `quotations` DROP FOREIGN KEY `quotations_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_receipt_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_location_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_attachments` DROP FOREIGN KEY `sklad_receipt_attachments_receipt_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_issue_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_location_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_session_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_location_id_fkey`;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `idx_bank_accounts_is_default` ON `bank_accounts`(`is_default`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_receipts_receipt_number_key` ON `sklad_receipts`(`receipt_number`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_receipts_supplier_id` ON `sklad_receipts`(`supplier_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_receipts_received_by` ON `sklad_receipts`(`received_by`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_issues_issue_number_key` ON `sklad_issues`(`issue_number`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_issues_project_id` ON `sklad_issues`(`project_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_issues_issued_by` ON `sklad_issues`(`issued_by`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_inventory_sessions_session_number_key` ON `sklad_inventory_sessions`(`session_number`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_3` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `quotations` ADD CONSTRAINT `quotations_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE 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 RESTRICT 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 CASCADE 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 CASCADE 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 RESTRICT 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 CASCADE 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 RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_orders` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`po_number` VARCHAR(50) NULL,
|
||||
`customer_id` INTEGER NULL,
|
||||
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
|
||||
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||
`apply_vat` BOOLEAN NULL DEFAULT true,
|
||||
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
|
||||
`order_date` DATE NULL,
|
||||
`delivery_date` DATE NULL,
|
||||
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||
`delivery_terms` VARCHAR(500) NULL,
|
||||
`payment_terms` VARCHAR(500) NULL,
|
||||
`issued_by` VARCHAR(255) NULL,
|
||||
`notes` TEXT NULL,
|
||||
`internal_notes` TEXT NULL,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
|
||||
INDEX `issued_orders_customer_id`(`customer_id`),
|
||||
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_order_items` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issued_order_id` INTEGER NOT NULL,
|
||||
`description` VARCHAR(500) NULL,
|
||||
`item_description` TEXT NULL,
|
||||
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
|
||||
`unit` VARCHAR(20) NULL,
|
||||
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
|
||||
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
|
||||
INDEX `issued_order_id`(`issued_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `company_settings` ADD COLUMN `project_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `project_type_code` VARCHAR(10) NULL;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Drop the three DEAD document "export" permissions and make the 12 surviving
|
||||
-- document permissions reproducible on a fresh production database.
|
||||
--
|
||||
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
|
||||
-- backend — every PDF/export route is guarded by `*.view`. They only ever
|
||||
-- toggled frontend buttons and showed up as dead checkboxes in role management.
|
||||
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
|
||||
-- enforced on >=1 route and must survive.
|
||||
--
|
||||
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
|
||||
-- fresh production DB never had ANY of them. This migration both removes the 3
|
||||
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
|
||||
-- prisma/seed.ts column set exactly (name, display_name, module, description,
|
||||
-- created_at; role_permissions has only the composite PK).
|
||||
--
|
||||
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
|
||||
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
|
||||
-- unique `permissions.name` key and on the `role_permissions` composite PK.
|
||||
|
||||
-- 1. Remove the dead export role assignments first (FK-safe: children before
|
||||
-- parents). No-op when the permissions/assignments don't exist.
|
||||
DELETE FROM `role_permissions`
|
||||
WHERE `permission_id` IN (
|
||||
SELECT `id` FROM `permissions`
|
||||
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
|
||||
);
|
||||
|
||||
-- 2. Remove the dead export permissions themselves.
|
||||
DELETE FROM `permissions`
|
||||
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
|
||||
|
||||
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
|
||||
-- IGNORE skips any that already exist (unique `name`). display_name /
|
||||
-- description / module match prisma/seed.ts verbatim.
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
|
||||
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
|
||||
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
|
||||
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
|
||||
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
|
||||
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
|
||||
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
|
||||
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
|
||||
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
|
||||
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
|
||||
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
|
||||
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
|
||||
|
||||
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
|
||||
-- PK (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 (
|
||||
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
|
||||
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
|
||||
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
|
||||
);
|
||||
@@ -4,7 +4,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model attendance {
|
||||
@@ -68,7 +67,6 @@ model audit_logs {
|
||||
session_id String? @db.VarChar(128)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
|
||||
@@index([created_at], map: "idx_audit_log_created")
|
||||
@@index([action], map: "idx_audit_logs_action")
|
||||
@@index([created_at], map: "idx_audit_logs_created")
|
||||
@@index([entity_type, entity_id, created_at], map: "idx_audit_logs_entity")
|
||||
@@ -124,6 +122,8 @@ model bank_accounts {
|
||||
position Int? @default(0)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
@@index([is_default], map: "idx_bank_accounts_is_default")
|
||||
}
|
||||
|
||||
model company_settings {
|
||||
@@ -164,6 +164,10 @@ model company_settings {
|
||||
offer_number_pattern String? @db.VarChar(100)
|
||||
order_number_pattern String? @db.VarChar(100)
|
||||
invoice_number_pattern String? @db.VarChar(100)
|
||||
issued_order_number_pattern String? @db.VarChar(100)
|
||||
issued_order_type_code String? @db.VarChar(10)
|
||||
project_number_pattern String? @db.VarChar(100)
|
||||
project_type_code String? @db.VarChar(10)
|
||||
warehouse_receipt_prefix String? @db.VarChar(20)
|
||||
warehouse_receipt_number_pattern String? @db.VarChar(100)
|
||||
warehouse_issue_prefix String? @db.VarChar(20)
|
||||
@@ -189,6 +193,7 @@ model customers {
|
||||
sync_version Int? @default(0)
|
||||
invoices invoices[]
|
||||
orders orders[]
|
||||
issued_orders issued_orders[]
|
||||
projects projects[]
|
||||
quotations quotations[]
|
||||
}
|
||||
@@ -235,8 +240,8 @@ model invoices {
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
invoice_items invoice_items[]
|
||||
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_2")
|
||||
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
|
||||
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@index([due_date], map: "idx_invoices_due_date")
|
||||
@@ -354,14 +359,63 @@ model orders {
|
||||
invoices invoices[]
|
||||
order_items order_items[]
|
||||
order_sections order_sections[]
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_2")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_2")
|
||||
projects projects[]
|
||||
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@index([quotation_id], map: "quotation_id")
|
||||
}
|
||||
|
||||
model issued_orders {
|
||||
id Int @id @default(autoincrement())
|
||||
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
||||
customer_id Int?
|
||||
status issued_orders_status @default(draft)
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
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)
|
||||
order_date DateTime? @db.Date
|
||||
delivery_date DateTime? @db.Date
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
delivery_terms String? @db.VarChar(500)
|
||||
payment_terms String? @db.VarChar(500)
|
||||
issued_by String? @db.VarChar(255)
|
||||
notes String? @db.Text
|
||||
internal_notes String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
issued_order_items issued_order_items[]
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
|
||||
|
||||
@@index([customer_id], map: "issued_orders_customer_id")
|
||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||
}
|
||||
|
||||
model issued_order_items {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
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)
|
||||
position Int? @default(0)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
||||
|
||||
@@index([issued_order_id], map: "issued_order_id")
|
||||
}
|
||||
|
||||
enum issued_orders_status {
|
||||
draft
|
||||
sent
|
||||
confirmed
|
||||
completed
|
||||
cancelled
|
||||
}
|
||||
|
||||
model permissions {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique(map: "name") @db.VarChar(50)
|
||||
@@ -405,9 +459,9 @@ model projects {
|
||||
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")
|
||||
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_3")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
|
||||
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
|
||||
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@index([responsible_user_id], map: "fk_projects_responsible_user")
|
||||
@@ -452,7 +506,7 @@ model quotations {
|
||||
orders orders[]
|
||||
projects projects[]
|
||||
quotation_items quotation_items[]
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "quotations_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
|
||||
scope_sections scope_sections[]
|
||||
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@ -777,7 +831,7 @@ model sklad_items {
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
category sklad_categories? @relation(fields: [category_id], references: [id])
|
||||
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
|
||||
batches sklad_batches[]
|
||||
item_locations sklad_item_locations[]
|
||||
receipt_lines sklad_receipt_lines[]
|
||||
@@ -799,8 +853,8 @@ model sklad_batches {
|
||||
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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
|
||||
issue_lines sklad_issue_lines[]
|
||||
|
||||
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
|
||||
@@ -813,8 +867,8 @@ model sklad_item_locations {
|
||||
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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
|
||||
@@map("sklad_item_locations")
|
||||
@@ -822,7 +876,7 @@ model sklad_item_locations {
|
||||
|
||||
model sklad_receipts {
|
||||
id Int @id @default(autoincrement())
|
||||
receipt_number String? @db.VarChar(50)
|
||||
receipt_number String? @unique @db.VarChar(50)
|
||||
supplier_id Int?
|
||||
delivery_note_number String? @db.VarChar(100)
|
||||
delivery_note_date DateTime? @db.DateTime(0)
|
||||
@@ -832,11 +886,13 @@ model sklad_receipts {
|
||||
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])
|
||||
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
|
||||
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
|
||||
items sklad_receipt_lines[]
|
||||
attachments sklad_receipt_attachments[]
|
||||
|
||||
@@index([supplier_id], map: "sklad_receipts_supplier_id")
|
||||
@@index([received_by], map: "sklad_receipts_received_by")
|
||||
@@map("sklad_receipts")
|
||||
}
|
||||
|
||||
@@ -849,9 +905,9 @@ model sklad_receipt_lines {
|
||||
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])
|
||||
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
|
||||
batch sklad_batches?
|
||||
|
||||
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
|
||||
@@ -867,14 +923,14 @@ model sklad_receipt_attachments {
|
||||
file_path String @db.VarChar(500)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
|
||||
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sklad_receipt_attachments")
|
||||
}
|
||||
|
||||
model sklad_issues {
|
||||
id Int @id @default(autoincrement())
|
||||
issue_number String? @db.VarChar(50)
|
||||
issue_number String? @unique @db.VarChar(50)
|
||||
project_id Int
|
||||
issued_by Int?
|
||||
notes String? @db.Text
|
||||
@@ -882,10 +938,12 @@ model sklad_issues {
|
||||
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])
|
||||
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
|
||||
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
|
||||
items sklad_issue_lines[]
|
||||
|
||||
@@index([project_id], map: "sklad_issues_project_id")
|
||||
@@index([issued_by], map: "sklad_issues_issued_by")
|
||||
@@map("sklad_issues")
|
||||
}
|
||||
|
||||
@@ -899,11 +957,11 @@ model sklad_issue_lines {
|
||||
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])
|
||||
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
|
||||
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([issue_id], map: "sklad_issue_lines_issue_id")
|
||||
@@map("sklad_issue_lines")
|
||||
@@ -921,9 +979,9 @@ model sklad_reservations {
|
||||
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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
|
||||
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
|
||||
issue_lines sklad_issue_lines[]
|
||||
|
||||
@@index([item_id, status], map: "sklad_reservations_item_status")
|
||||
@@ -932,7 +990,7 @@ model sklad_reservations {
|
||||
|
||||
model sklad_inventory_sessions {
|
||||
id Int @id @default(autoincrement())
|
||||
session_number String? @db.VarChar(50)
|
||||
session_number String? @unique @db.VarChar(50)
|
||||
notes String? @db.Text
|
||||
status sklad_inventory_status @default(DRAFT)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
@@ -953,9 +1011,9 @@ model sklad_inventory_lines {
|
||||
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])
|
||||
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
|
||||
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([session_id], map: "sklad_inventory_lines_session_id")
|
||||
@@map("sklad_inventory_lines")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import "dotenv/config";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Use the shared, adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
// dotenv is imported first so DATABASE_URL is set before database.ts reads it.
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
const PERMISSIONS: {
|
||||
name: string;
|
||||
@@ -94,12 +95,6 @@ const PERMISSIONS: {
|
||||
module: "offers",
|
||||
description: "Mazat nabídky",
|
||||
},
|
||||
{
|
||||
name: "offers.export",
|
||||
display_name: "Exportovat nabídku",
|
||||
module: "offers",
|
||||
description: "Exportovat nabídky do PDF",
|
||||
},
|
||||
|
||||
// Objednávky
|
||||
{
|
||||
@@ -126,12 +121,6 @@ const PERMISSIONS: {
|
||||
module: "orders",
|
||||
description: "Mazat objednávky",
|
||||
},
|
||||
{
|
||||
name: "orders.export",
|
||||
display_name: "Exportovat objednávku",
|
||||
module: "orders",
|
||||
description: "Exportovat objednávky do PDF",
|
||||
},
|
||||
|
||||
// Faktury
|
||||
{
|
||||
@@ -158,12 +147,6 @@ const PERMISSIONS: {
|
||||
module: "invoices",
|
||||
description: "Mazat faktury",
|
||||
},
|
||||
{
|
||||
name: "invoices.export",
|
||||
display_name: "Exportovat fakturu",
|
||||
module: "invoices",
|
||||
description: "Exportovat faktury do PDF",
|
||||
},
|
||||
|
||||
// Projekty
|
||||
{
|
||||
@@ -315,6 +298,22 @@ const PERMISSIONS: {
|
||||
];
|
||||
|
||||
async function main() {
|
||||
// HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and
|
||||
// role_permissions (and seeds an admin/admin user) — purely dev-convenience
|
||||
// behavior. Production baseline data must come from tracked Prisma migrations,
|
||||
// never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on
|
||||
// production so the destructive wipe can never hit a live database.
|
||||
if (process.env.APP_ENV === "production") {
|
||||
throw new Error(
|
||||
"Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " +
|
||||
"Seed maže všechna oprávnění a je určen pouze pro vývoj. " +
|
||||
"Produkční data musí být zavedena migrací.\n" +
|
||||
"Refusing to run: prisma/seed.ts must NEVER run on production " +
|
||||
"(APP_ENV=production). It wipes all permissions and is dev-only; " +
|
||||
"seed production baseline data via a migration instead.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Seeding permissions...");
|
||||
|
||||
// 1. Clear existing permissions and re-insert current set
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import "../src/config/env";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import fs from "fs";
|
||||
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
async function main() {
|
||||
if (!nasFinancialsManager.isConfigured()) {
|
||||
@@ -51,6 +51,39 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read-back verification before we null out the ONLY DB copy of the bytes.
|
||||
// The manager uses writeFileSync (synchronous) and returns a relative path;
|
||||
// resolve it back to disk and confirm the file exists and its size matches
|
||||
// the source buffer. A 0-byte/truncated/corrupt write would otherwise lose
|
||||
// the invoice permanently once file_data is set to null. If verification
|
||||
// fails we KEEP file_data and report the row as failed.
|
||||
const expectedSize = rec.file_data.length;
|
||||
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
||||
let writtenSize = -1;
|
||||
if (readBack) {
|
||||
writtenSize = readBack.data.length;
|
||||
} else {
|
||||
// Fall back to a direct stat in case readReceivedInvoice's path guard
|
||||
// rejects an otherwise-valid path; either way we need a confirmed size.
|
||||
try {
|
||||
writtenSize = fs.statSync(
|
||||
(nasFinancialsManager as unknown as { basePath: string }).basePath +
|
||||
"/" +
|
||||
result.filePath,
|
||||
).size;
|
||||
} catch {
|
||||
writtenSize = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (writtenSize !== expectedSize) {
|
||||
console.error(
|
||||
` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`,
|
||||
);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.received_invoices.update({
|
||||
where: { id: rec.id },
|
||||
data: { file_path: result.filePath, file_data: null },
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import crypto from 'crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import "dotenv/config";
|
||||
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
|
||||
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function decrypt(ciphertext: string, keyHex: string): string {
|
||||
const key = Buffer.from(keyHex, 'hex');
|
||||
const [ivHex, encHex, tagHex] = ciphertext.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string, keyHex: string): string {
|
||||
const key = Buffer.from(keyHex, 'hex');
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
||||
}
|
||||
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
|
||||
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
|
||||
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
|
||||
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
|
||||
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
|
||||
// legacy user. These key-parameterized helpers accept the old/new keys passed on
|
||||
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
|
||||
const decrypt = (ciphertext: string, keyHex: string): string =>
|
||||
decryptWithKey(ciphertext, keyHex);
|
||||
const encrypt = (plaintext: string, keyHex: string): string =>
|
||||
encryptWithKey(plaintext, keyHex);
|
||||
|
||||
async function main() {
|
||||
const oldKey = process.argv[2];
|
||||
const newKey = process.argv[3];
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
if (!oldKey || !newKey) {
|
||||
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
|
||||
console.error(
|
||||
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -46,12 +40,14 @@ async function main() {
|
||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||
const reEncrypted = encrypt(decrypted, newKey);
|
||||
decrypt(reEncrypted, newKey); // verify round-trip
|
||||
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
|
||||
console.log(
|
||||
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
|
||||
}
|
||||
}
|
||||
console.log('\nDry run complete. No changes made.');
|
||||
console.log("\nDry run complete. No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,13 +55,19 @@ async function main() {
|
||||
for (const user of users) {
|
||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||
const reEncrypted = encrypt(decrypted, newKey);
|
||||
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: { totp_secret: reEncrypted },
|
||||
});
|
||||
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nAll TOTP secrets re-encrypted successfully.');
|
||||
console.log("\nAll TOTP secrets re-encrypted successfully.");
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -65,15 +65,21 @@ describe("ai.service cost + budget", () => {
|
||||
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
||||
});
|
||||
|
||||
it("sums only the current month's spend", async () => {
|
||||
it("sums only the current month's spend (excludes prior months)", async () => {
|
||||
// Measure the baseline so the assertion is robust to any OTHER current-month
|
||||
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
|
||||
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
|
||||
// upper bound.
|
||||
const before = await getMonthSpendUsd();
|
||||
|
||||
await recordUsage({
|
||||
userId: null,
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
}); // $3 this month
|
||||
// An old row (last year) must NOT count.
|
||||
}); // +$3 this month
|
||||
// An old row (year 2000) must NOT count toward the current month.
|
||||
await prisma.ai_usage.create({
|
||||
data: {
|
||||
kind: KIND,
|
||||
@@ -84,9 +90,11 @@ describe("ai.service cost + budget", () => {
|
||||
created_at: new Date("2000-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
const spend = await getMonthSpendUsd();
|
||||
expect(spend).toBeGreaterThanOrEqual(3);
|
||||
expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded
|
||||
|
||||
const after = await getMonthSpendUsd();
|
||||
// Only the $3 current-month row moved the needle; the year-2000 $3 is
|
||||
// excluded. The delta is exactly $3 (not $6).
|
||||
expect(after - before).toBeCloseTo(3, 6);
|
||||
});
|
||||
|
||||
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
||||
@@ -94,7 +102,11 @@ describe("ai.service cost + budget", () => {
|
||||
// Under budget → null (allowed)
|
||||
expect(await assertBudgetAvailable()).toBeNull();
|
||||
// Push spend over budget for this month, then it must block with 402.
|
||||
await prisma.ai_usage.create({
|
||||
// Tag with a unique marker so we can delete EXACTLY this row at the end of
|
||||
// the test — that keeps the inflated spend from leaking into any other
|
||||
// current-month test regardless of execution order (the beforeEach kind=KIND
|
||||
// cleanup would catch it too, but cleaning in-test makes it order-proof).
|
||||
const overBudget = await prisma.ai_usage.create({
|
||||
data: {
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
@@ -104,9 +116,17 @@ describe("ai.service cost + budget", () => {
|
||||
created_at: new Date(),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const blocked = await assertBudgetAvailable();
|
||||
expect(blocked).not.toBeNull();
|
||||
expect(blocked?.status).toBe(402);
|
||||
} finally {
|
||||
// Remove the over-budget row immediately so it cannot inflate
|
||||
// getMonthSpendUsd for any subsequently-running test.
|
||||
await prisma.ai_usage
|
||||
.delete({ where: { id: overBudget.id } })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("isConfigured reflects the API key presence", () => {
|
||||
|
||||
@@ -1,10 +1,72 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config/env";
|
||||
import prisma from "../config/database";
|
||||
|
||||
// Prefix for fixtures so cleanup never touches real data.
|
||||
const N = "auth_svc_test_";
|
||||
|
||||
let testUserId: number;
|
||||
let testUsername: string;
|
||||
let testRoleName: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up any leftover fixtures from a previous failed run.
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
|
||||
// returns a full AuthData. We don't need a specific role — any active user
|
||||
// works — but we record its role so we can assert roleName precisely.
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
// DUMMY_HASH used elsewhere in the suite; we never verify a password
|
||||
// here, only that the token's `sub` resolves to this user.
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Auth",
|
||||
last_name: "Service",
|
||||
is_active: true,
|
||||
role_id: role.id,
|
||||
},
|
||||
include: { roles: true },
|
||||
});
|
||||
testUserId = user.id;
|
||||
testUsername = user.username;
|
||||
testRoleName = user.roles?.name ?? null;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
describe("auth service", () => {
|
||||
describe("verifyAccessToken", () => {
|
||||
it("returns AuthData for a valid token signed for a real user", async () => {
|
||||
const token = jwt.sign(
|
||||
{ sub: testUserId, username: testUsername, role: testRoleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
|
||||
);
|
||||
|
||||
const result = await verifyAccessToken(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe(testUserId);
|
||||
expect(result!.username).toBe(testUsername);
|
||||
expect(result!.roleName).toBe(testRoleName);
|
||||
// Permissions resolve to an array (admins get all, others get role perms).
|
||||
expect(Array.isArray(result!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
@@ -35,6 +97,14 @@ describe("auth service", () => {
|
||||
});
|
||||
|
||||
describe("hashToken", () => {
|
||||
it("matches the known SHA-256 vector for a fixed input", () => {
|
||||
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
|
||||
// different but still 64-hex-deterministic hash) is caught.
|
||||
expect(hashToken("hello")).toBe(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
);
|
||||
});
|
||||
|
||||
it("produces deterministic SHA-256 hex output", () => {
|
||||
const t1 = hashToken("hello");
|
||||
const t2 = hashToken("hello");
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "auth_test_";
|
||||
const TEST_USERNAME = `${N}user`;
|
||||
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
|
||||
|
||||
let testUserId: number;
|
||||
|
||||
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
|
||||
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
|
||||
const cookies = res.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
return arr.find((c: string) => c.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run, plus its tokens.
|
||||
const leftovers = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (leftovers.length) {
|
||||
await prisma.refresh_tokens.deleteMany({
|
||||
where: { user_id: { in: leftovers.map((u) => u.id) } },
|
||||
});
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
}
|
||||
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
// Hash the known password with the SAME bcrypt cost the app uses so login's
|
||||
// bcrypt.compare succeeds.
|
||||
const passwordHash = await bcrypt.hash(
|
||||
TEST_PASSWORD,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_USERNAME,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: passwordHash,
|
||||
first_name: "Auth",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refresh_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 for a valid user with the wrong password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.access_token.length).toBeGreaterThan(0);
|
||||
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
expect(body.data.user.username).toBe(TEST_USERNAME);
|
||||
|
||||
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
|
||||
const cookie = extractCookie(res, "refresh_token");
|
||||
expect(cookie).toBeTruthy();
|
||||
const rawCookie = rawSetCookie(res, "refresh_token")!;
|
||||
expect(rawCookie).toMatch(/HttpOnly/i);
|
||||
expect(rawCookie).toMatch(/SameSite=Strict/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/refresh", () => {
|
||||
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid/unknown refresh token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: "not-a-real-token" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
|
||||
// First log in to obtain a valid refresh cookie.
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
expect(loginRes.statusCode).toBe(200);
|
||||
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
|
||||
expect(originalRefresh).toBeTruthy();
|
||||
|
||||
const refreshRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: originalRefresh },
|
||||
});
|
||||
expect(refreshRes.statusCode).toBe(200);
|
||||
const body = refreshRes.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
|
||||
// The refresh token must be ROTATED: a new cookie value, different from
|
||||
// the one we presented.
|
||||
const rotated = extractCookie(refreshRes, "refresh_token");
|
||||
expect(rotated).toBeTruthy();
|
||||
expect(rotated).not.toBe(originalRefresh);
|
||||
|
||||
// Reuse-detection: presenting the now-consumed original token must fail.
|
||||
const reuseRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: originalRefresh },
|
||||
});
|
||||
expect(reuseRes.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/logout", () => {
|
||||
it("clears refresh token cookie", async () => {
|
||||
it("clears the refresh token cookie", async () => {
|
||||
// Log in so logout has a real token to delete.
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
const refresh = extractCookie(loginRes, "refresh_token")!;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/logout",
|
||||
cookies: { refresh_token: refresh },
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// The response must actively clear the cookie — an expiry in the past
|
||||
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
|
||||
const raw = rawSetCookie(res, "refresh_token");
|
||||
expect(raw).toBeTruthy();
|
||||
const cleared =
|
||||
/Max-Age=0/i.test(raw!) ||
|
||||
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
|
||||
/^refresh_token=;/.test(raw!);
|
||||
expect(cleared).toBe(true);
|
||||
|
||||
// The deleted token must no longer be usable for refresh.
|
||||
const reuseRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: refresh },
|
||||
});
|
||||
expect(reuseRes.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
152
src/__tests__/bank-accounts.test.ts
Normal file
152
src/__tests__/bank-accounts.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import bankAccountsRoutes from "../routes/admin/bank-accounts";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
// Fixture marker so cleanup never touches real data.
|
||||
const N = "bank_accounts_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let token: string;
|
||||
const createdIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(bankAccountsRoutes, {
|
||||
prefix: "/api/admin/bank-accounts",
|
||||
});
|
||||
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Admin role bypasses the settings.banking permission guard.
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Test setup: no admin role found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Bank",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
token = jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.bank_accounts
|
||||
.deleteMany({ where: { id: { in: createdIds } } })
|
||||
.catch(() => {});
|
||||
}
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const auth = { authorization: () => `Bearer ${token}` };
|
||||
|
||||
describe("bank accounts — flat-body create/update persists all fields", () => {
|
||||
it("creates an account from a flat top-level body and persists every field", async () => {
|
||||
const payload = {
|
||||
account_name: `${N}Hlavní účet`,
|
||||
bank_name: "Komerční banka",
|
||||
account_number: "123456789/0100",
|
||||
iban: "CZ6508000000192000145399",
|
||||
bic: "KOMBCZPP",
|
||||
currency: "CZK",
|
||||
is_default: true,
|
||||
};
|
||||
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload,
|
||||
});
|
||||
expect(post.statusCode).toBe(201);
|
||||
const id = post.json().data.id as number;
|
||||
expect(typeof id).toBe("number");
|
||||
createdIds.push(id);
|
||||
|
||||
// Read back via the list endpoint and confirm NONE of the fields are blank.
|
||||
const row = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
expect(row?.account_name).toBe(payload.account_name);
|
||||
expect(row?.bank_name).toBe(payload.bank_name);
|
||||
expect(row?.account_number).toBe(payload.account_number);
|
||||
expect(row?.iban).toBe(payload.iban);
|
||||
expect(row?.bic).toBe(payload.bic);
|
||||
expect(row?.currency).toBe("CZK");
|
||||
expect(row?.is_default).toBe(true);
|
||||
});
|
||||
|
||||
it("updates an account from a flat body that also carries the id", async () => {
|
||||
const created = await prisma.bank_accounts.create({
|
||||
data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" },
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
// Mirrors the client payload: spread fields + id (id routes the URL/method,
|
||||
// is declared in the schema, and is ignored by the route which uses :id).
|
||||
const put = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/bank-accounts/${created.id}`,
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload: {
|
||||
id: created.id,
|
||||
account_name: `${N}upraveno`,
|
||||
bank_name: "Nová banka",
|
||||
iban: "CZ9999",
|
||||
currency: "EUR",
|
||||
is_default: false,
|
||||
},
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
|
||||
const row = await prisma.bank_accounts.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(row?.account_name).toBe(`${N}upraveno`);
|
||||
expect(row?.bank_name).toBe("Nová banka");
|
||||
expect(row?.iban).toBe("CZ9999");
|
||||
expect(row?.currency).toBe("EUR");
|
||||
});
|
||||
|
||||
it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => {
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload: { bank: { account_name: `${N}nested`, bank_name: "X" } },
|
||||
});
|
||||
expect(post.statusCode).toBe(400);
|
||||
|
||||
// And nothing was persisted under that name.
|
||||
const leaked = await prisma.bank_accounts.findFirst({
|
||||
where: { account_name: `${N}nested` },
|
||||
});
|
||||
expect(leaked).toBeNull();
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
132
src/__tests__/company-settings-numbering.test.ts
Normal file
132
src/__tests__/company-settings-numbering.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import companySettingsRoutes from "../routes/admin/company-settings";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "settings_numbering_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let token: string;
|
||||
|
||||
// Snapshot the prior values of the four numbering fields we mutate so we can
|
||||
// restore them in afterAll — other tests read company_settings and must not be
|
||||
// poisoned by our writes.
|
||||
let priorValues: {
|
||||
issued_order_number_pattern: string | null;
|
||||
issued_order_type_code: string | null;
|
||||
project_number_pattern: string | null;
|
||||
project_type_code: string | null;
|
||||
} | null = null;
|
||||
let settingsId: number | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(companySettingsRoutes, {
|
||||
prefix: "/api/admin/company-settings",
|
||||
});
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run.
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Attach the fixture user to the admin role so the PUT/GET permission guards
|
||||
// (settings.company / settings.system) pass.
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Test setup: no admin role found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Settings",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
token = jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
|
||||
// Snapshot existing settings so we can restore after the suite.
|
||||
const existing = await prisma.company_settings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
issued_order_number_pattern: true,
|
||||
issued_order_type_code: true,
|
||||
project_number_pattern: true,
|
||||
project_type_code: true,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
settingsId = existing.id;
|
||||
priorValues = {
|
||||
issued_order_number_pattern: existing.issued_order_number_pattern,
|
||||
issued_order_type_code: existing.issued_order_type_code,
|
||||
project_number_pattern: existing.project_number_pattern,
|
||||
project_type_code: existing.project_type_code,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Restore the four numbering fields to their pre-test values.
|
||||
if (settingsId !== null && priorValues !== null) {
|
||||
await prisma.company_settings
|
||||
.update({ where: { id: settingsId }, data: priorValues })
|
||||
.catch(() => {});
|
||||
}
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("company-settings numbering — issued orders + projects round-trip", () => {
|
||||
it("persists and returns issued_order_* and project_* numbering fields", async () => {
|
||||
const payload = {
|
||||
issued_order_number_pattern: "{YY}V{NNNN}",
|
||||
issued_order_type_code: "72",
|
||||
project_number_pattern: "{YY}P{NNNN}",
|
||||
project_type_code: "73",
|
||||
};
|
||||
|
||||
const put = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/admin/company-settings/",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().success).toBe(true);
|
||||
|
||||
const get = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/company-settings/",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(get.statusCode).toBe(200);
|
||||
const data = get.json().data;
|
||||
expect(data.issued_order_number_pattern).toBe("{YY}V{NNNN}");
|
||||
expect(data.issued_order_type_code).toBe("72");
|
||||
expect(data.project_number_pattern).toBe("{YY}P{NNNN}");
|
||||
expect(data.project_type_code).toBe("73");
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/company-settings/",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
||||
import {
|
||||
CreateCustomerSchema,
|
||||
UpdateCustomerSchema,
|
||||
} from "../schemas/customers.schema";
|
||||
|
||||
describe("UpdateCustomerSchema", () => {
|
||||
it("rejects empty name", () => {
|
||||
@@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a name longer than 255 chars", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts nullable address/identity fields set to null", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({
|
||||
street: null,
|
||||
city: null,
|
||||
postal_code: null,
|
||||
country: null,
|
||||
company_id: null,
|
||||
vat_id: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateCustomerSchema", () => {
|
||||
it("requires a name", () => {
|
||||
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a full valid payload with all optional fields", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
street: "Main St 1",
|
||||
city: "Praha",
|
||||
postal_code: "11000",
|
||||
country: "CZ",
|
||||
company_id: "12345678",
|
||||
vat_id: "CZ12345678",
|
||||
custom_fields: [{ label: "Note", value: "VIP" }],
|
||||
customer_field_order: ["name", "street"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom_fields as an array of arbitrary objects", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: [
|
||||
{ label: "a", value: 1 },
|
||||
{ nested: { deep: true } },
|
||||
"raw string",
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects custom_fields that is not an array", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: { not: "an array" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects custom_fields longer than the 100-item cap", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects customer_field_order entries that are not strings", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
customer_field_order: ["name", 5],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an over-length nullable field (vat_id > 255)", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
vat_id: "x".repeat(256),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
116
src/__tests__/drafts-aggregation.test.ts
Normal file
116
src/__tests__/drafts-aggregation.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createInvoice, getInvoiceStats } from "../services/invoices.service";
|
||||
import { createOffer } from "../services/offers.service";
|
||||
|
||||
// These tests prove that DRAFT documents (a status introduced by the DB-drafts
|
||||
// feature) are NOT counted in period reporting aggregations. A draft is not a
|
||||
// real financial document, so its VAT/amount/created-count must be excluded.
|
||||
//
|
||||
// They hit the real `app_test` DB (per the suite convention) via the service
|
||||
// layer directly. Every row we create is tracked and removed in afterEach so we
|
||||
// leave the DB clean, and we reset the touched number sequences so a finalized
|
||||
// (issued/active) doc created here doesn't leak a consumed number into other
|
||||
// files.
|
||||
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["invoice", "offer"] } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("drafts excluded from invoice monthly stats (VAT)", () => {
|
||||
it("a draft invoice's VAT is NOT in vat_month while an issued one IS", async () => {
|
||||
// Pick a far-future month so no seeded/other-test invoices fall in it and
|
||||
// the totals are deterministic. issue_date drives the stats window.
|
||||
const year = 2099;
|
||||
const month = 7;
|
||||
const issueDate = `${year}-0${month}-15`;
|
||||
|
||||
// Single CZK line, 21% VAT: base 1000, VAT 210. Identical on both docs so
|
||||
// the only difference between "draft excluded" and "issued counted" is the
|
||||
// status filter under test.
|
||||
const item = { quantity: 1, unit_price: 1000, vat_rate: 21 };
|
||||
|
||||
const draft = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
issue_date: issueDate,
|
||||
items: [item],
|
||||
});
|
||||
invoiceIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.invoice_number).toBeNull();
|
||||
|
||||
// Sanity: with ONLY the draft present, vat_month must be empty (draft excluded).
|
||||
const draftOnly = await getInvoiceStats(month, year);
|
||||
expect(draftOnly.vat_month).toEqual([]);
|
||||
expect(draftOnly.vat_month_czk).toBe(0);
|
||||
|
||||
const issued = await createInvoice({
|
||||
status: "issued",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
issue_date: issueDate,
|
||||
items: [item],
|
||||
});
|
||||
invoiceIds.push(issued.id);
|
||||
expect(issued.status).toBe("issued");
|
||||
|
||||
// Now the issued invoice's VAT (210 CZK) must be counted — and ONLY that
|
||||
// one, not the draft's (which would double it to 420 if drafts leaked in).
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
const czk = stats.vat_month.find((v) => v.currency === "CZK");
|
||||
expect(czk?.amount).toBe(210);
|
||||
expect(stats.vat_month_czk).toBe(210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drafts excluded from quotations created-this-month count", () => {
|
||||
it("a draft offer is NOT counted; an active one IS", async () => {
|
||||
// Mirror the dashboard route's exact aggregation: count quotations created
|
||||
// in the window, excluding drafts. Use a tight window around `now` (the
|
||||
// route uses the current month) and assert the delta this test introduces.
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
const countNonDraft = () =>
|
||||
prisma.quotations.count({
|
||||
where: {
|
||||
status: { not: "draft" },
|
||||
created_at: { gte: monthStart, lt: monthEnd },
|
||||
},
|
||||
});
|
||||
|
||||
const before = await countNonDraft();
|
||||
|
||||
const draft = await createOffer({ status: "draft" });
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
|
||||
// The draft must not move the non-draft count.
|
||||
expect(await countNonDraft()).toBe(before);
|
||||
|
||||
const active = await createOffer({ status: "active" });
|
||||
if ("error" in active)
|
||||
throw new Error(`createOffer failed: ${active.error}`);
|
||||
offerIds.push(active.id);
|
||||
|
||||
// The active offer is a real document → the count goes up by exactly 1.
|
||||
expect(await countNonDraft()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
275
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
275
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
previewIssuedOrderNumber,
|
||||
previewInvoiceNumber,
|
||||
previewOfferNumber,
|
||||
} from "../services/numbering.service";
|
||||
import {
|
||||
createIssuedOrder,
|
||||
updateIssuedOrder,
|
||||
deleteIssuedOrder,
|
||||
} from "../services/issued-orders.service";
|
||||
import {
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
} from "../services/invoices.service";
|
||||
import {
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
} from "../services/offers.service";
|
||||
|
||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||
const issuedOrderIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of issuedOrderIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
issuedOrderIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
// Reset every sequence this suite may have touched so preview values are
|
||||
// stable across tests and we don't leak consumed numbers into other files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["issued_order", "invoice", "offer"] } },
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issued orders */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("issued-order drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({}); // defaults to draft
|
||||
issuedOrderIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.po_number).toBeNull();
|
||||
// Preview is unchanged → the sequence was not consumed.
|
||||
const after = (await previewIssuedOrderNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||
const expected = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: draft.id },
|
||||
});
|
||||
expect(row?.status).toBe("sent");
|
||||
expect(row?.po_number).toBe(expected);
|
||||
expect("po_number" in res && res.po_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||
const draft = await createIssuedOrder({});
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
const first = (
|
||||
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
||||
)?.po_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "issued_order" },
|
||||
});
|
||||
|
||||
// draft -> sent is no longer valid (already sent); the number must not change.
|
||||
// Drive a second finalize-style update that keeps status sent.
|
||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
const second = (
|
||||
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
||||
)?.po_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "issued_order" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
||||
const a = await createIssuedOrder({});
|
||||
const b = await createIssuedOrder({});
|
||||
issuedOrderIds.push(a.id, b.id);
|
||||
expect(a.po_number).toBeNull();
|
||||
expect(b.po_number).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
await deleteIssuedOrder(draft.id);
|
||||
const after = (await previewIssuedOrderNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoices */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("invoice drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.invoice_number).toBeNull();
|
||||
const after = (await previewInvoiceNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
|
||||
const expected = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
const res = await updateInvoice(draft.id, { status: "issued" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.status).toBe("issued");
|
||||
expect(row?.invoice_number).toBe(expected);
|
||||
expect("invoice_number" in res && res.invoice_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing twice does not re-number / double-consume", async () => {
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
await updateInvoice(draft.id, { status: "issued" });
|
||||
const first = (
|
||||
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
||||
)?.invoice_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "invoice" },
|
||||
});
|
||||
|
||||
// issued -> issued is a no-op status-wise; the number must stay put.
|
||||
await updateInvoice(draft.id, { status: "issued" });
|
||||
const second = (
|
||||
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
||||
)?.invoice_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "invoice" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two invoice drafts coexist with null numbers", async () => {
|
||||
const a = await createInvoice({});
|
||||
const b = await createInvoice({});
|
||||
invoiceIds.push(a.id, b.id);
|
||||
expect(a.invoice_number).toBeNull();
|
||||
expect(b.invoice_number).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing", async () => {
|
||||
const before = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
await deleteInvoice(draft.id);
|
||||
const after = (await previewInvoiceNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.quotation_number).toBeNull();
|
||||
const after = await previewOfferNumber();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
|
||||
const expected = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
|
||||
const res = await updateOffer(draft.id, { status: "active" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.status).toBe("active");
|
||||
expect(row?.quotation_number).toBe(expected);
|
||||
expect("quotation_number" in res && res.quotation_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing twice does not re-number / double-consume", async () => {
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
|
||||
await updateOffer(draft.id, { status: "active" });
|
||||
const first = (
|
||||
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
||||
)?.quotation_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "offer" },
|
||||
});
|
||||
|
||||
// active -> active is a no-op; the number must stay put.
|
||||
await updateOffer(draft.id, { status: "active" });
|
||||
const second = (
|
||||
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
||||
)?.quotation_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "offer" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two offer drafts coexist with null numbers", async () => {
|
||||
const a = await createOffer({});
|
||||
const b = await createOffer({});
|
||||
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
|
||||
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
|
||||
offerIds.push(a.id, b.id);
|
||||
expect(a.quotation_number).toBeNull();
|
||||
expect(b.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("an offer created already-finalized (status active) numbers immediately", async () => {
|
||||
const expected = await previewOfferNumber();
|
||||
const offer = await createOffer({ status: "active" });
|
||||
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
|
||||
offerIds.push(offer.id);
|
||||
expect(offer.quotation_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing", async () => {
|
||||
const before = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
await deleteOffer(draft.id);
|
||||
const after = await previewOfferNumber();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -32,3 +32,37 @@ describe("env validation", () => {
|
||||
expect(config.db.url).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("env validation — failure path", () => {
|
||||
// The config singleton runs its validation at module-load time and is then
|
||||
// cached by the module system, so we cannot re-import it with bad env to
|
||||
// observe a throw without complex module-registry resetting. Instead we test
|
||||
// the exact predicates config/env.ts uses to reject bad input — this is the
|
||||
// logic that protects the boot, and the loaded config above proves the
|
||||
// happy path. (Predicates kept in sync with src/config/env.ts.)
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
it("rejects a too-short / non-hex JWT_SECRET", () => {
|
||||
expect(HEX64_RE.test("short")).toBe(false);
|
||||
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
|
||||
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
|
||||
// The real config's secret must pass the very same check.
|
||||
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range or non-numeric PORT", () => {
|
||||
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
|
||||
expect(portValid(parseInt("0", 10))).toBe(false);
|
||||
expect(portValid(parseInt("70000", 10))).toBe(false);
|
||||
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
|
||||
expect(portValid(config.port)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
|
||||
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
|
||||
expect(expiryValid(0)).toBe(false);
|
||||
expect(expiryValid(-1)).toBe(false);
|
||||
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
|
||||
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { toCzk, getRate } from "../services/exchange-rates";
|
||||
import {
|
||||
toCzk,
|
||||
getRate,
|
||||
__resetRateCacheForTest,
|
||||
} from "../services/exchange-rates";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
@@ -8,6 +12,11 @@ global.fetch = mockFetch;
|
||||
describe("exchange-rates", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
// The rate cache is module-level and persists across tests. Without this
|
||||
// reset, the first test to populate "today" would short-circuit every
|
||||
// later mockFetch resolution (cache hit), making assertions order-dependent
|
||||
// and asserting stale rates. Reset it so each test gets a clean fetch.
|
||||
__resetRateCacheForTest();
|
||||
});
|
||||
|
||||
describe("toCzk", () => {
|
||||
@@ -43,6 +52,21 @@ describe("exchange-rates", () => {
|
||||
const result = await toCzk(100, "EUR");
|
||||
expect(result).toBe(2500);
|
||||
});
|
||||
|
||||
it("honours amount != 1 (currency quoted per 100 units)", async () => {
|
||||
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
|
||||
// is for `amount` units, so the per-unit rate is rate/amount. Here the
|
||||
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
|
||||
// If the service ignored `amount` it would compute 100*250 = 25000.
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||
}),
|
||||
});
|
||||
const result = await toCzk(100, "HUF");
|
||||
expect(result).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRate", () => {
|
||||
@@ -60,5 +84,17 @@ describe("exchange-rates", () => {
|
||||
});
|
||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
|
||||
it("returns the per-unit rate for amount != 1", async () => {
|
||||
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||
}),
|
||||
});
|
||||
const result = await getRate("HUF");
|
||||
expect(result).toBe(2.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,31 @@ export async function buildApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
export function extractCookie(response: any, name: string): string | undefined {
|
||||
/**
|
||||
* Minimal structural shape of the only thing extractCookie needs from a
|
||||
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
|
||||
* (from `app.inject`) and a plain supertest response without coupling to either.
|
||||
*/
|
||||
interface ResponseWithCookies {
|
||||
headers: Record<string, string | string[] | number | undefined>;
|
||||
}
|
||||
|
||||
export function extractCookie(
|
||||
response: ResponseWithCookies,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
for (const c of arr) {
|
||||
if (typeof c !== "string") continue;
|
||||
if (c.startsWith(`${name}=`)) {
|
||||
return c.split(";")[0].split("=")[1];
|
||||
// The value is everything from the first "=" up to the first ";".
|
||||
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
|
||||
// padding) is not truncated — `split("=")[1]` would drop everything after
|
||||
// the second "=".
|
||||
const pair = c.split(";")[0];
|
||||
return pair.slice(pair.indexOf("=") + 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
|
||||
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
|
||||
// for `Number(decimal)`) is actually caught.
|
||||
|
||||
const dec = (n: number | string) => new Prisma.Decimal(n);
|
||||
|
||||
// Helper so each test reads as a small declarative table of lines.
|
||||
function buildInvoice(opts: {
|
||||
applyVat: boolean;
|
||||
vatRate: number | null;
|
||||
currency?: string;
|
||||
items: Array<{
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
vat_rate: number | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
apply_vat: opts.applyVat,
|
||||
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
|
||||
currency: opts.currency ?? "CZK",
|
||||
invoice_items: opts.items.map((i) => ({
|
||||
quantity: i.quantity === null ? null : dec(i.quantity),
|
||||
unit_price: i.unit_price === null ? null : dec(i.unit_price),
|
||||
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const inv = buildInvoice({
|
||||
applyVat: false,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||
});
|
||||
|
||||
@@ -24,32 +49,72 @@ describe("invoiceTotalWithVat", () => {
|
||||
// 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 },
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: Array.from({ length: 3 }, () => ({
|
||||
quantity: 1,
|
||||
unit_price: 33.33,
|
||||
vat_rate: 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 },
|
||||
},
|
||||
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
|
||||
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
|
||||
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
|
||||
// line proves the null line contributes nothing while the rest still totals.
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: null, unit_price: null, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
|
||||
],
|
||||
};
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(121);
|
||||
});
|
||||
|
||||
it("returns 0 when every line is null", () => {
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: null, unit_price: null, vat_rate: null }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||
});
|
||||
|
||||
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
|
||||
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
|
||||
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
|
||||
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
|
||||
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
|
||||
// Subtotal = 350, VAT = 63, Total = 413
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 15,
|
||||
items: [
|
||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 0 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(413);
|
||||
});
|
||||
|
||||
it("handles a negative (discount) line correctly", () => {
|
||||
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
|
||||
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
|
||||
// Subtotal = 800, VAT = 168, Total = 968
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: -200, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||
});
|
||||
});
|
||||
|
||||
317
src/__tests__/issued-orders.test.ts
Normal file
317
src/__tests__/issued-orders.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
generateIssuedOrderNumber,
|
||||
previewIssuedOrderNumber,
|
||||
releaseIssuedOrderNumber,
|
||||
} from "../services/numbering.service";
|
||||
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
|
||||
import {
|
||||
computeIssuedOrderTotals,
|
||||
createIssuedOrder,
|
||||
getIssuedOrder,
|
||||
listIssuedOrders,
|
||||
updateIssuedOrder,
|
||||
deleteIssuedOrder,
|
||||
} from "../services/issued-orders.service";
|
||||
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
|
||||
});
|
||||
|
||||
describe("issued-order numbering", () => {
|
||||
it("generates sequential numbers", async () => {
|
||||
const year = new Date().getFullYear();
|
||||
const a = await generateIssuedOrderNumber(year);
|
||||
const b = await generateIssuedOrderNumber(year);
|
||||
expect(Number(b.number)).toBe(Number(a.number) + 1);
|
||||
});
|
||||
|
||||
it("preview does not consume the sequence", async () => {
|
||||
const year = new Date().getFullYear();
|
||||
const p1 = await previewIssuedOrderNumber(year);
|
||||
const p2 = await previewIssuedOrderNumber(year);
|
||||
expect(p1.number).toBe(p2.number);
|
||||
});
|
||||
|
||||
it("release frees only the highest number", async () => {
|
||||
const year = new Date().getFullYear();
|
||||
await generateIssuedOrderNumber(year); // seq 1
|
||||
await generateIssuedOrderNumber(year); // seq 2
|
||||
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
|
||||
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
|
||||
// Releasing a number that is NOT the current highest is a no-op.
|
||||
await releaseIssuedOrderNumber(year, "00000000");
|
||||
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
|
||||
// Releasing the current highest reclaims it.
|
||||
await releaseIssuedOrderNumber(year, c.number);
|
||||
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateIssuedOrderSchema", () => {
|
||||
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
||||
const ok = CreateIssuedOrderSchema.safeParse({
|
||||
customer_id: "5",
|
||||
vat_rate: "21",
|
||||
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
if (ok.success) expect(ok.data.customer_id).toBe(5);
|
||||
|
||||
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createdIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
});
|
||||
|
||||
async function makeCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "Dodavatel s.r.o." },
|
||||
});
|
||||
createdCustomerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||
it("adds VAT on top of net, rounded per line", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[
|
||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||
],
|
||||
true,
|
||||
21,
|
||||
);
|
||||
expect(t.subtotal).toBe(250);
|
||||
expect(t.vat_amount).toBe(48);
|
||||
expect(t.total).toBe(298);
|
||||
});
|
||||
|
||||
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
|
||||
false,
|
||||
21,
|
||||
);
|
||||
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
|
||||
});
|
||||
|
||||
it("falls back to the document rate when a line rate is null", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[{ quantity: 1, unit_price: 100, vat_rate: null }],
|
||||
true,
|
||||
15,
|
||||
);
|
||||
expect(t.vat_amount).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIssuedOrder", () => {
|
||||
it("defaults to draft with NO PO number, stores items", async () => {
|
||||
const c = await makeCustomer();
|
||||
const order = await createIssuedOrder({
|
||||
customer_id: c.id,
|
||||
items: [
|
||||
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIds.push(order.id);
|
||||
// Deferred numbering: a draft carries no number.
|
||||
expect(order.po_number).toBeNull();
|
||||
expect(order.status).toBe("draft");
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(100);
|
||||
});
|
||||
|
||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const order = await createIssuedOrder({ status: "sent" });
|
||||
createdIds.push(order.id);
|
||||
expect(order.po_number).toBe(before);
|
||||
expect(order.status).toBe("sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateIssuedOrder status transitions", () => {
|
||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||
const order = await createIssuedOrder({});
|
||||
createdIds.push(order.id);
|
||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||
expect("error" in ok).toBe(false);
|
||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||
expect("error" in bad && bad.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("locks items once confirmed", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
||||
});
|
||||
createdIds.push(order.id);
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||
await updateIssuedOrder(order.id, {
|
||||
items: [{ description: "B", quantity: 9, unit_price: 99 }],
|
||||
});
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].description).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrder", () => {
|
||||
it("returns customer_name and valid_transitions", async () => {
|
||||
const c = await makeCustomer();
|
||||
const order = await createIssuedOrder({ customer_id: c.id });
|
||||
createdIds.push(order.id);
|
||||
const detail = await getIssuedOrder(order.id);
|
||||
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
||||
expect(detail?.valid_transitions).toContain("sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIssuedOrder", () => {
|
||||
it("deletes, cascades items, frees the latest number", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
// Finalize so the order has a real (consumed) number to free on delete.
|
||||
const finalized = await updateIssuedOrder(order.id, { status: "sent" });
|
||||
const before = "po_number" in finalized ? finalized.po_number : null;
|
||||
expect(before).toBeTruthy();
|
||||
await deleteIssuedOrder(order.id);
|
||||
expect(
|
||||
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
})
|
||||
).length,
|
||||
).toBe(0);
|
||||
expect((await previewIssuedOrderNumber()).number).toBe(before);
|
||||
});
|
||||
|
||||
it("releases the number against the order's creation year, not the current year", async () => {
|
||||
const priorYear = new Date().getFullYear() - 1;
|
||||
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
||||
// so the PO number matches the prior year's current highest.
|
||||
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
||||
const order = await createIssuedOrder({ po_number: poNumber });
|
||||
createdIds.push(order.id);
|
||||
// Backdate creation into the prior year so delete derives that year.
|
||||
await prisma.issued_orders.update({
|
||||
where: { id: order.id },
|
||||
data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) },
|
||||
});
|
||||
await deleteIssuedOrder(order.id);
|
||||
// The prior-year sequence must be decremented (1 -> 0). With the old
|
||||
// current-year bug it would stay at 1 (no current-year row to match).
|
||||
const seq = await prisma.number_sequences.findFirst({
|
||||
where: { type: "issued_order", year: priorYear },
|
||||
});
|
||||
expect(seq?.last_number).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listIssuedOrders month filter", () => {
|
||||
it("returns only orders whose order_date is in the given month", async () => {
|
||||
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
|
||||
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
|
||||
createdIds.push(inMonth.id, outMonth.id);
|
||||
const res = await listIssuedOrders({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
sort: "id",
|
||||
order: "desc",
|
||||
month: 3,
|
||||
year: 2026,
|
||||
});
|
||||
const ids = res.data.map((o) => o.id);
|
||||
expect(ids).toContain(inMonth.id);
|
||||
expect(ids).not.toContain(outMonth.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderIssuedOrderHtml", () => {
|
||||
const order = {
|
||||
po_number: "26720001",
|
||||
order_date: new Date("2026-06-09T12:00:00"),
|
||||
delivery_date: null,
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
notes: "<p>Pozn</p><script>alert(1)</script>",
|
||||
delivery_terms: null,
|
||||
payment_terms: null,
|
||||
issued_by: null,
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
description: "Materiál",
|
||||
item_description: "Detailní popis položky",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 100,
|
||||
vat_rate: 21,
|
||||
},
|
||||
];
|
||||
|
||||
const issuer = { name: "Jan Novák" };
|
||||
|
||||
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
{ name: "Dodavatel s.r.o." },
|
||||
{ company_name: "Naše firma" },
|
||||
"cs",
|
||||
issuer,
|
||||
);
|
||||
expect(html).toContain("26720001");
|
||||
expect(html).toContain("Materiál");
|
||||
// Optional item sub-line.
|
||||
expect(html).toContain("Detailní popis položky");
|
||||
// PO direction: the customer record is the Dodavatel (supplier), our
|
||||
// company is the Odběratel (buyer).
|
||||
expect(html).toContain("Dodavatel s.r.o.");
|
||||
expect(html).toContain("Naše firma");
|
||||
expect(html).toContain("Dodavatel");
|
||||
expect(html).toContain("Odběratel");
|
||||
});
|
||||
|
||||
it("strips script tags from notes", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||
expect(html).toContain("Jan Novák");
|
||||
expect(html).not.toContain("E-mail:");
|
||||
// The old two-column signature footer is gone.
|
||||
expect(html).not.toContain("footer-row");
|
||||
expect(html).not.toContain("Schválil:");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createProject } from "../services/projects.service";
|
||||
import { createOrder } from "../services/orders.service";
|
||||
import {
|
||||
createOrder,
|
||||
createOrderFromQuotation,
|
||||
deleteOrder,
|
||||
} from "../services/orders.service";
|
||||
import { previewProjectNumber } from "../services/numbering.service";
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
const createdProjectIds: number[] = [];
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdQuotationIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdProjectIds)
|
||||
await prisma.projects.deleteMany({ where: { id } });
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
createdProjectIds.length = 0;
|
||||
createdOrderIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
createdQuotationIds.length = 0;
|
||||
// Orders and projects now draw from SEPARATE sequences — reset both.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the current `last_number` of a (type, current-year) sequence row, or 0
|
||||
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
|
||||
* prove each generator advances its OWN row by exactly 1.
|
||||
*/
|
||||
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
|
||||
const row = await prisma.number_sequences.findFirst({
|
||||
where: { type, year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
return row?.last_number ?? 0;
|
||||
}
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
@@ -25,61 +52,171 @@ const baseOrder = {
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
|
||||
* passed the test when a create failed) while still narrowing the union via the
|
||||
* `in` operator so the success-branch fields stay typed.
|
||||
*/
|
||||
function failResult(res: unknown): never {
|
||||
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||
}
|
||||
|
||||
describe("createOrder (manual, optional linked project)", () => {
|
||||
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
|
||||
// Capture BOTH sequence counters before the create so we can prove each
|
||||
// generator advanced its OWN row (not that the project consumed the shared
|
||||
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
|
||||
// pass even if the project drew from `shared` — the deltas catch that.
|
||||
const sharedBefore = await seqLastNumber("shared");
|
||||
const projectBefore = await seqLastNumber("project");
|
||||
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Ruční zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
expect(project?.project_number).toBe(res.order_number);
|
||||
// The auto-created project now draws from the dedicated project sequence,
|
||||
// so its number is distinct from the order's shared number. They relate
|
||||
// only via the order_id FK, not by sharing a number.
|
||||
expect(project?.project_number).toBeTruthy();
|
||||
expect(project?.project_number).not.toBe(res.order_number);
|
||||
expect(project?.order_id).toBe(res.id);
|
||||
|
||||
// Sequence isolation: the order consumed exactly one `shared` number and
|
||||
// the project consumed exactly one `project` number — each generator
|
||||
// advanced its OWN row by 1. A regression where the project draws from the
|
||||
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
|
||||
const sharedAfter = await seqLastNumber("shared");
|
||||
const projectAfter = await seqLastNumber("project");
|
||||
expect(sharedAfter - sharedBefore).toBe(1);
|
||||
expect(projectAfter - projectBefore).toBe(1);
|
||||
});
|
||||
|
||||
it("creates only the order when create_project=false", async () => {
|
||||
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
expect(res.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared pool coherence (tracking)", () => {
|
||||
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||
if ("id" in o1) {
|
||||
createdOrderIds.push(o1.id);
|
||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||
}
|
||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||
if ("project_number" in p) createdProjectIds.push(p.id);
|
||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||
if ("id" in o2) createdOrderIds.push(o2.id);
|
||||
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
|
||||
// Seed a minimal quotation (no items/sections needed — createOrderFrom
|
||||
// Quotation copies whatever is present and both arrays may be empty).
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FROMQ-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const n1 = "id" in o1 ? o1.order_number : null;
|
||||
const np = "project_number" in p ? p.project_number : null;
|
||||
const n2 = "id" in o2 ? o2.order_number : null;
|
||||
expect(new Set([n1, np, n2]).size).toBe(3);
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: quotation.id,
|
||||
customerOrderNumber: "PO-FROMQ",
|
||||
});
|
||||
if (!("data" in res) || !res.data) failResult(res);
|
||||
const orderId = res.data.order_id;
|
||||
createdOrderIds.push(orderId);
|
||||
|
||||
const order = await prisma.orders.findUnique({ where: { id: orderId } });
|
||||
const project = await prisma.projects.findFirst({
|
||||
where: { order_id: orderId },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
|
||||
// Primary flow: the auto-created project must get a non-empty number from
|
||||
// the dedicated `project` sequence (code 73), DISTINCT from the order's
|
||||
// shared number (code 71). Pre-split they shared one number.
|
||||
expect(project).toBeTruthy();
|
||||
expect(project?.project_number).toBeTruthy();
|
||||
expect(order?.order_number).toBeTruthy();
|
||||
expect(project?.project_number).not.toBe(order?.order_number);
|
||||
expect(project?.order_id).toBe(orderId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder releases the project number (release regression)", () => {
|
||||
it("frees the auto-created project's number back to the project sequence", async () => {
|
||||
// The project sequence is reset in afterEach, so the preview before the
|
||||
// create is the number createOrder will consume for the project.
|
||||
const previewBefore = await previewProjectNumber();
|
||||
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Smazatelná zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
const orderId = res.id!;
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
expect(project?.project_number).toBe(previewBefore);
|
||||
|
||||
// After the order delete, the project row is gone AND its number must have
|
||||
// been released (decremented) so the preview returns the freed number — not
|
||||
// a permanent gap. This is the fix #1 regression guard.
|
||||
const del = await deleteOrder(orderId);
|
||||
if ("error" in del) failResult(del);
|
||||
|
||||
const gone = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
expect(gone).toBeNull();
|
||||
|
||||
const previewAfter = await previewProjectNumber();
|
||||
expect(previewAfter).toBe(previewBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("separate order vs project sequences (coherence)", () => {
|
||||
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||
if (!("id" in o1)) failResult(o1);
|
||||
createdOrderIds.push(o1.id!);
|
||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||
|
||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||
if (!("project_number" in p)) failResult(p);
|
||||
createdProjectIds.push(p.id);
|
||||
|
||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||
if (!("id" in o2)) failResult(o2);
|
||||
createdOrderIds.push(o2.id!);
|
||||
|
||||
// All three numbers must be present (non-null) AND distinct. Orders draw
|
||||
// from the `shared` sequence (code 71); projects from the dedicated
|
||||
// `project` sequence (code 73), so they never collide.
|
||||
expect(o1.order_number).toBeTruthy();
|
||||
expect(p.project_number).toBeTruthy();
|
||||
expect(o2.order_number).toBeTruthy();
|
||||
expect(
|
||||
new Set([o1.order_number, p.project_number, o2.order_number]).size,
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProject (manual, standalone)", () => {
|
||||
it("assigns a shared number and is not linked to an order", async () => {
|
||||
it("assigns a project number and is not linked to an order", async () => {
|
||||
const result = await createProject({
|
||||
name: "Test projekt",
|
||||
status: "aktivni",
|
||||
});
|
||||
expect("project_number" in result).toBe(true);
|
||||
if (!("project_number" in result)) return;
|
||||
if (!("project_number" in result)) failResult(result);
|
||||
createdProjectIds.push(result.id);
|
||||
expect(typeof result.project_number).toBe("string");
|
||||
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||
@@ -112,9 +249,8 @@ describe("createOrder with price line item + attachment", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
const items = await prisma.order_items.findMany({
|
||||
where: { order_id: res.id },
|
||||
});
|
||||
@@ -122,21 +258,25 @@ describe("createOrder with price line item + attachment", () => {
|
||||
expect(Number(items[0].unit_price)).toBe(12345);
|
||||
});
|
||||
|
||||
it("stores an uploaded PO attachment", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake");
|
||||
it("stores an uploaded PO attachment with the exact bytes", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
|
||||
const res = await createOrder(
|
||||
{ ...baseOrder, create_project: false },
|
||||
buf,
|
||||
"po.pdf",
|
||||
);
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: res.id },
|
||||
select: { attachment_name: true, attachment_data: true },
|
||||
});
|
||||
expect(order?.attachment_name).toBe("po.pdf");
|
||||
// Verify the stored content/size, not just truthiness: round-trip the
|
||||
// bytes and compare length + value against what we uploaded.
|
||||
expect(order?.attachment_data).toBeTruthy();
|
||||
const stored = Buffer.from(order!.attachment_data!);
|
||||
expect(stored.length).toBe(buf.length);
|
||||
expect(stored.equals(buf)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
56
src/__tests__/nas-document-manager.test.ts
Normal file
56
src/__tests__/nas-document-manager.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasDocumentManager } from "../services/nas-financials-manager";
|
||||
|
||||
/**
|
||||
* Generic NAS archival manager backing both the invoices NAS (NAS_INVOICES) and
|
||||
* the orders NAS (NAS_ORDERS). Uses an isolated temp dir via the constructor
|
||||
* seam so it is deterministic and passes even where the real NAS (Z:) is not
|
||||
* mounted. Layout: {base}/Vydané/YYYY/MM/<n>.pdf — diacritics preserved.
|
||||
*/
|
||||
describe("NasDocumentManager issued-PDF round-trip", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasDocumentManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-doc-"));
|
||||
nas = new NasDocumentManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("saves an issued PDF under Vydané/YYYY/MM/<number>.pdf and reads it back", () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake order pdf bytes", "utf8");
|
||||
const rel = nas.saveIssuedPdf("25P0001", 2026, 6, buf);
|
||||
|
||||
expect(rel).toBe("Vydané/2026/06/25P0001.pdf");
|
||||
const onDisk = path.join(tmpBase, "Vydané", "2026", "06", "25P0001.pdf");
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
const read = nas.readIssued(rel!);
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.fileName).toBe("25P0001.pdf");
|
||||
expect(read!.data.equals(buf)).toBe(true);
|
||||
});
|
||||
|
||||
it("cleanIssued removes a previously-saved PDF across year/month folders", () => {
|
||||
const buf = Buffer.from("pdf", "utf8");
|
||||
const rel = nas.saveIssuedPdf("25P0002", 2026, 6, buf);
|
||||
expect(nas.readIssued(rel!)).not.toBeNull();
|
||||
|
||||
nas.cleanIssued("25P0002");
|
||||
expect(nas.readIssued(rel!)).toBeNull();
|
||||
});
|
||||
|
||||
it("an unconfigured (empty basePath) manager is not configured and saves nothing", () => {
|
||||
const blank = new NasDocumentManager("");
|
||||
expect(blank.isConfigured()).toBe(false);
|
||||
expect(
|
||||
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
describe("NasFileManager path traversal", () => {
|
||||
@@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Positive-path coverage: a legitimate (non-traversal) path inside a real
|
||||
* project folder is ACCEPTED — it passes resolveProjectPath validation and
|
||||
* reaches the underlying fs op (success → null). Without this, a
|
||||
* "reject-everything" regression in the traversal guard would still pass every
|
||||
* rejection test above. Uses the constructor basePath seam + a temp dir so it
|
||||
* is deterministic even where the real NAS (Z:) is not mounted.
|
||||
*/
|
||||
describe("NasFileManager accepts legitimate paths", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
const NUM = "29990001";
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
// Create a real project folder <number>_<name> so findProjectFolder resolves.
|
||||
nas.createProjectFolder(NUM, "Test");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
|
||||
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
|
||||
|
||||
const result = await nas.deleteItem(NUM, "doc.pdf");
|
||||
// null = success: the path was accepted and the file actually removed.
|
||||
expect(result).toBeNull();
|
||||
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||
|
||||
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
|
||||
expect(result).toBeNull();
|
||||
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
|
||||
|
||||
const result = await nas.deleteItem(NUM, "../keep.pdf");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
// The guard rejected the path before any fs op — nothing was removed.
|
||||
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
|
||||
// With a real project folder, the source `from.pdf` resolves cleanly, so
|
||||
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
|
||||
// the source must stay put (no move outside the project folder).
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||
|
||||
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
|
||||
// Nothing escaped the project folder.
|
||||
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,55 @@ import {
|
||||
isSharedNumberTaken,
|
||||
previewOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
generateProjectNumber,
|
||||
previewProjectNumber,
|
||||
isProjectNumberTaken,
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
/**
|
||||
* Assert two consecutive document numbers (a) keep an identical format / year
|
||||
* prefix and (b) increment by exactly one — without hard-coding which
|
||||
* `company_settings` pattern is configured.
|
||||
*
|
||||
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
|
||||
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
|
||||
* of the two strings (the unchanged format/year portion) and parse the
|
||||
* remaining differing suffixes as integers — these are the rendered sequences.
|
||||
*/
|
||||
function assertStrictIncrement(num1: string, num2: string) {
|
||||
expect(num1).not.toBe(num2);
|
||||
expect(num2.length).toBe(num1.length); // same width ⇒ same format
|
||||
|
||||
let i = 0;
|
||||
while (i < num1.length && num1[i] === num2[i]) i++;
|
||||
const commonPrefix = num1.slice(0, i);
|
||||
// The format/year portion is shared and non-empty (the numbers don't differ
|
||||
// from the very first character).
|
||||
expect(commonPrefix.length).toBeGreaterThan(0);
|
||||
|
||||
const seq1 = Number(num1.slice(i));
|
||||
const seq2 = Number(num2.slice(i));
|
||||
expect(Number.isNaN(seq1)).toBe(false);
|
||||
expect(Number.isNaN(seq2)).toBe(false);
|
||||
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the (type, year) sequence row to a high `last_number` so the next
|
||||
* generated numbers land far above any real order/quotation in the test DB —
|
||||
* the generator's skip-taken retry then never fires and the strict +1
|
||||
* assertion is deterministic.
|
||||
*/
|
||||
async function seedSequence(type: string, lastNumber: number) {
|
||||
await prisma.number_sequences.deleteMany({ where: { type } });
|
||||
await prisma.number_sequences.create({
|
||||
data: { type, year: YEAR, last_number: lastNumber },
|
||||
});
|
||||
}
|
||||
|
||||
describe("generateSharedNumber", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
@@ -24,10 +70,94 @@ describe("generateSharedNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
// Seed high so neither generated number collides with a real order/project
|
||||
// → the skip-taken retry never fires and the increment is strictly +1.
|
||||
await seedSequence("shared", 900000);
|
||||
const num1 = await generateSharedNumber();
|
||||
const num2 = await generateSharedNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateProjectNumber", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||
});
|
||||
|
||||
it("returns a non-empty string", async () => {
|
||||
const num = await generateProjectNumber();
|
||||
expect(typeof num).toBe("string");
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
// Seed high so neither generated number collides with a real project →
|
||||
// the skip-taken retry never fires and the increment is strictly +1.
|
||||
await seedSequence("project", 900000);
|
||||
const num1 = await generateProjectNumber();
|
||||
const num2 = await generateProjectNumber();
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
|
||||
it("previewProjectNumber returns a number not yet taken by a project", async () => {
|
||||
const preview = await previewProjectNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(preview.length).toBeGreaterThan(0);
|
||||
expect(await isProjectNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-sequence isolation (shared vs project)", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => {
|
||||
// Seed the two sequences to DISTINCT known highs so neither generated
|
||||
// number collides with a real row (skip-taken retry never fires) and a
|
||||
// cross-contamination bug is unambiguous.
|
||||
await seedSequence("shared", 900000);
|
||||
await seedSequence("project", 500000);
|
||||
|
||||
await generateSharedNumber();
|
||||
// After a shared allocation only the shared row may have moved.
|
||||
let shared = await prisma.number_sequences.findFirst({
|
||||
where: { type: "shared", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
let project = await prisma.number_sequences.findFirst({
|
||||
where: { type: "project", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
expect(shared?.last_number).toBe(900001);
|
||||
expect(project?.last_number).toBe(500000);
|
||||
|
||||
await generateProjectNumber();
|
||||
// After a project allocation only the project row may have moved; the
|
||||
// shared row must still sit at 900001.
|
||||
shared = await prisma.number_sequences.findFirst({
|
||||
where: { type: "shared", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
project = await prisma.number_sequences.findFirst({
|
||||
where: { type: "project", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
expect(shared?.last_number).toBe(900001);
|
||||
expect(project?.last_number).toBe(500001);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +176,11 @@ describe("generateOfferNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
await seedSequence("offer", 900000);
|
||||
const num1 = await generateOfferNumber();
|
||||
const num2 = await generateOfferNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,9 +208,17 @@ describe("previewSharedNumber", () => {
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number. With the
|
||||
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
||||
// Seed the counter HIGH so the preview lands far above any real
|
||||
// order/project in the test DB. Without this, a real row could already
|
||||
// hold the first sequence number — making the create collide on the unique
|
||||
// column, or making `second !== first` pass for the wrong reason (because
|
||||
// the counter happened to already point past `first`).
|
||||
await seedSequence("shared", 900000);
|
||||
const first = await previewSharedNumber();
|
||||
// Preconditions: the seeded preview really is free (so the create below is
|
||||
// safe) and is exactly what the generator would assign next.
|
||||
expect(await isSharedNumberTaken(first)).toBe(false);
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||
});
|
||||
@@ -117,9 +256,14 @@ describe("previewOfferNumber", () => {
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number; with the
|
||||
// skip-taken logic it's guaranteed free, so we can claim it.
|
||||
// Seed HIGH so the preview lands above any real quotation in the test DB
|
||||
// (see the shared-number test for the rationale): the create can't collide
|
||||
// and the skip-advance is genuinely exercised rather than passing because a
|
||||
// real row already advanced the preview.
|
||||
await seedSequence("offer", 900000);
|
||||
const first = await previewOfferNumber();
|
||||
expect(await isOfferNumberTaken(first)).toBe(false);
|
||||
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: { quotation_number: first },
|
||||
});
|
||||
|
||||
280
src/__tests__/offer-invoice-totals.test.ts
Normal file
280
src/__tests__/offer-invoice-totals.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices";
|
||||
|
||||
// Per-currency total aggregation for the offers, issued-invoices and
|
||||
// received-invoices lists. These hit the real `app_test` DB via the service
|
||||
// layer (suite convention) and prove the totals fns sum each document's TOTAL
|
||||
// per currency across the WHOLE filtered set, and that the filtering `where`
|
||||
// (search / month-year / status) is respected — mirroring order-totals.test.ts.
|
||||
//
|
||||
// For invoices a far-future month/year is used so seeded/other-test rows can't
|
||||
// fall in the window and skew the deterministic sums. Offers have NO date
|
||||
// filter, so they are isolated by a unique `project_code` searched on instead.
|
||||
// Received invoices store amount as GROSS (VAT-inclusive), summed directly.
|
||||
|
||||
const STATS_YEAR = 2097;
|
||||
const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01)
|
||||
|
||||
// Unique marker so the offers test (which has no date filter) can scope its
|
||||
// aggregation to only the rows it created via a `search` on project_code.
|
||||
const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`;
|
||||
|
||||
const createdOfferIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
const createdReceivedIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOfferIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
for (const id of createdReceivedIds)
|
||||
await prisma.received_invoices.deleteMany({ where: { id } });
|
||||
createdOfferIds.length = 0;
|
||||
createdInvoiceIds.length = 0;
|
||||
createdReceivedIds.length = 0;
|
||||
// Finalized offers/invoices consume their sequences — reset so we don't leak
|
||||
// a consumed number into sibling test files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["offer", "invoice"] } },
|
||||
});
|
||||
});
|
||||
|
||||
function amountFor(
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined {
|
||||
return totals.find((t) => t.currency === currency)?.amount;
|
||||
}
|
||||
|
||||
describe("getOfferTotals per-currency aggregation", () => {
|
||||
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
|
||||
// (enrichQuotation math).
|
||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOffer({
|
||||
status: "draft", // draft so no offer number is consumed
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||
createdOfferIds.push(res.id);
|
||||
return res.id;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a status filter narrows the result", async () => {
|
||||
const mk = async (status: string) => {
|
||||
const res = await createOffer({
|
||||
status,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||
createdOfferIds.push(res.id);
|
||||
return res.id;
|
||||
};
|
||||
|
||||
// Two drafts + one invalidated, all sharing the marker.
|
||||
await mk("draft");
|
||||
await mk("draft");
|
||||
await mk("invalidated");
|
||||
|
||||
// Marker only: all three count -> 3 x 1210 = 3630.
|
||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3630);
|
||||
|
||||
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
|
||||
const onlyDraft = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "draft",
|
||||
});
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Invalidated alone -> 1210.
|
||||
const onlyInvalid = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "invalidated",
|
||||
});
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => {
|
||||
// issue_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeInvoiceTotals) but with a single line per invoice
|
||||
// here the result matches the whole-subtotal math.
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
|
||||
const mk = async (
|
||||
currency: string,
|
||||
qty: number,
|
||||
price: number,
|
||||
status = "draft",
|
||||
) => {
|
||||
const inv = await createInvoice({
|
||||
status,
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
createdInvoiceIds.push(inv.id);
|
||||
return inv.id;
|
||||
};
|
||||
|
||||
it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// CZK #1: 2 x 1000 @21% = 2420
|
||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one issued (both 1210). Next month: one draft.
|
||||
await mk("CZK", 1, 1000, "draft");
|
||||
// An already-issued invoice numbers immediately; that's fine for the sum.
|
||||
const issued = await createInvoice({
|
||||
status: "issued",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
createdInvoiceIds.push(issued.id);
|
||||
|
||||
const next = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: nextDateStr,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
createdInvoiceIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
const whole = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'issued' in the target month -> 1210.
|
||||
const onlyIssued = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "issued",
|
||||
});
|
||||
expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one invoice there -> 1210.
|
||||
const nextMonth = await getInvoiceListTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("received-invoices list-totals per-currency aggregation (GROSS)", () => {
|
||||
// No service create for received invoices — rows are written directly. The
|
||||
// list/totals `where` filters by the month/year COLUMNS (not issue_date).
|
||||
// amount is GROSS, summed directly per currency.
|
||||
const mkReceived = async (currency: string, amount: number) => {
|
||||
const row = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
supplier_name: "Totals Test Supplier",
|
||||
amount,
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
createdReceivedIds.push(row.id);
|
||||
return row.id;
|
||||
};
|
||||
|
||||
it("sums GROSS amount per currency over the full filtered set", async () => {
|
||||
// Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is.
|
||||
await mkReceived("CZK", 2420);
|
||||
await mkReceived("CZK", 605);
|
||||
await mkReceived("EUR", 363);
|
||||
|
||||
const { totals } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a search filter change the result", async () => {
|
||||
await mkReceived("CZK", 1000);
|
||||
// A row in the NEXT month must not count toward the target month's total.
|
||||
const other = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
supplier_name: "Totals Test Supplier",
|
||||
amount: 9999,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
createdReceivedIds.push(other.id);
|
||||
|
||||
const { totals } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(1000);
|
||||
|
||||
// Search on the supplier name still scopes to the target-month row.
|
||||
const { totals: searched } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
search: "Totals Test Supplier",
|
||||
});
|
||||
expect(amountFor(searched, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
245
src/__tests__/order-totals.test.ts
Normal file
245
src/__tests__/order-totals.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOrder, getOrderTotals } from "../services/orders.service";
|
||||
import {
|
||||
createIssuedOrder,
|
||||
getIssuedOrderTotals,
|
||||
} from "../services/issued-orders.service";
|
||||
|
||||
// Per-currency total aggregation for both order lists. These hit the real
|
||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
|
||||
// set, and that the where (month/year + status) is respected.
|
||||
//
|
||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||
|
||||
const STATS_YEAR = 2097;
|
||||
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdIssuedIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdIssuedIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdOrderIds.length = 0;
|
||||
createdIssuedIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
// Finalized orders consume the shared/issued sequences — reset so we don't
|
||||
// leak a consumed number into sibling test files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "issued_order", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
function amountFor(
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined {
|
||||
return totals.find((t) => t.currency === currency)?.amount;
|
||||
}
|
||||
|
||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
||||
// applied on the whole subtotal (enrichOrder math).
|
||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOrder({
|
||||
status: "prijata",
|
||||
currency,
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
// created_at is auto-managed; pin it into the stats window so the
|
||||
// month/year filter is deterministic.
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const mk = async (status: string, monthOffset: number) => {
|
||||
const res = await createOrder({
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(
|
||||
STATS_YEAR,
|
||||
STATS_MONTH - 1 + monthOffset,
|
||||
15,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
// One 'prijata' in the target month, one 'stornovana' in the target month,
|
||||
// one 'prijata' in the NEXT month.
|
||||
await mk("prijata", 0);
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
||||
const whole = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
||||
const onlyPrijata = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "prijata",
|
||||
});
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1210.
|
||||
const nextMonth = await getOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
||||
// order here the result matches the on-the-whole subtotal math.
|
||||
// CZK #1: 2 x 1000 @21% = 2420
|
||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const o = await createIssuedOrder({
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: dateStr,
|
||||
items: [
|
||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(o.id);
|
||||
return o.id;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one already-sent (both 1210). Next month: one.
|
||||
const draft = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(draft.id);
|
||||
|
||||
const sent = await createIssuedOrder({
|
||||
status: "sent",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(sent.id);
|
||||
|
||||
const next = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: nextMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
const whole = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
||||
const onlySent = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "sent",
|
||||
});
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one order there -> 1210.
|
||||
const nextStats = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
114
src/__tests__/orders-list.test.ts
Normal file
114
src/__tests__/orders-list.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOrder, listOrders } from "../services/orders.service";
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdOrderIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
function failResult(res: unknown): never {
|
||||
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||
}
|
||||
|
||||
async function makeCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "Odběratel a.s." },
|
||||
});
|
||||
createdCustomerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
const baseListParams = {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
sort: "id",
|
||||
order: "desc" as const,
|
||||
};
|
||||
|
||||
describe("listOrders search filter", () => {
|
||||
it("returns the order when search matches its order_number", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
const list = await listOrders({
|
||||
...baseListParams,
|
||||
search: res.order_number!,
|
||||
});
|
||||
expect(list.data.map((o) => o.id)).toContain(res.id);
|
||||
});
|
||||
|
||||
it("excludes the order when search matches nothing", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
const list = await listOrders({
|
||||
...baseListParams,
|
||||
search: "ZZZ-NO-SUCH-ORDER-ZZZ",
|
||||
});
|
||||
expect(list.data.map((o) => o.id)).not.toContain(res.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listOrders month filter", () => {
|
||||
it("returns only orders whose created_at is in the given month", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
// Pin created_at to a known month so the range filter is deterministic.
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: { created_at: new Date(2026, 2, 15, 12, 0, 0) },
|
||||
});
|
||||
|
||||
const inMonth = await listOrders({
|
||||
...baseListParams,
|
||||
month: 3,
|
||||
year: 2026,
|
||||
});
|
||||
expect(inMonth.data.map((o) => o.id)).toContain(res.id);
|
||||
|
||||
const otherMonth = await listOrders({
|
||||
...baseListParams,
|
||||
month: 4,
|
||||
year: 2026,
|
||||
});
|
||||
expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import planRoutes from "../routes/admin/plan";
|
||||
import { localDateStr } from "../utils/date";
|
||||
import {
|
||||
resolveCell,
|
||||
resolveGrid,
|
||||
@@ -31,6 +32,14 @@ import {
|
||||
const N = "wh_plan_";
|
||||
|
||||
let adminUserId: number;
|
||||
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
|
||||
// tests (and as the second employee in the multi-user bulk test). Creating our
|
||||
// own user — instead of grabbing "the second user in the DB" — guarantees it is
|
||||
// never accidentally the admin (which would make `toEqual([])` pass for the
|
||||
// wrong reason on a single-user DB), and that it is genuinely distinct so the
|
||||
// 2-employee aggregate assertion can't collapse to 1.
|
||||
let scopeUserId: number;
|
||||
let scopeRoleId: number;
|
||||
let noPermUserId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -43,11 +52,44 @@ beforeAll(async () => {
|
||||
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;
|
||||
// Dedicated non-admin user + role for scoping/multi-user tests.
|
||||
const stamp = Date.now();
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: `scope_plan_${stamp}`,
|
||||
display_name: "Plan Scope Test",
|
||||
},
|
||||
});
|
||||
scopeRoleId = role.id;
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `scope_plan_${stamp}`,
|
||||
first_name: "Scope",
|
||||
last_name: "User",
|
||||
email: `scope_plan_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
scopeUserId = user.id;
|
||||
if (scopeUserId === adminUserId)
|
||||
throw new Error("scope user must be distinct from admin");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
|
||||
// leftover FK (none expected — its rows are note-prefixed and removed) can't
|
||||
// mask real failures.
|
||||
if (scopeUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: scopeUserId } })
|
||||
.catch(() => {});
|
||||
if (scopeRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: scopeRoleId } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
|
||||
const result = assertNotPastDate("2000-01-01", true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
|
||||
// Deterministic without mocking the clock: compute "today" exactly the way
|
||||
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
|
||||
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
|
||||
// would wrongly reject editing today's plan.
|
||||
const today = localDateStr(new Date());
|
||||
expect(assertNotPastDate(today, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects YESTERDAY (local Prague date) as a past date", () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1); // local-time yesterday
|
||||
const yesterday = localDateStr(d);
|
||||
const result = assertNotPastDate(yesterday, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false, // not admin
|
||||
);
|
||||
// A non-admin actor querying ANOTHER user's data is scoped to its own
|
||||
// user_id, so it sees none of the admin's entries. This can only be
|
||||
// [] for the right reason because scopeUserId !== adminUserId (asserted
|
||||
// in beforeAll).
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listOverrides(
|
||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false,
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
@@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => {
|
||||
});
|
||||
|
||||
it("aggregates across multiple employees", async () => {
|
||||
// Two genuinely distinct employees (admin + the dedicated scope user) so
|
||||
// `users` can't collapse to 1 if the DB happened to have a single user.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId, noPermUserId],
|
||||
user_ids: [adminUserId, scopeUserId],
|
||||
date_from: "2096-08-03", // Friday
|
||||
date_to: "2096-08-03",
|
||||
include_weekends: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
listPlanCategories,
|
||||
@@ -12,10 +12,28 @@ import {
|
||||
|
||||
const created: number[] = [];
|
||||
|
||||
// Every test category's slug starts with "test_" AND contains "_zz" (labels all
|
||||
// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids —
|
||||
// means a previous crashed run can't leave a `test_kategorie_zz` row behind that
|
||||
// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow
|
||||
// pattern avoids touching real seed categories (work/other/…).
|
||||
async function cleanupByPrefix() {
|
||||
await prisma.plan_categories.deleteMany({
|
||||
where: { key: { startsWith: "test_", contains: "_zz" } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanupByPrefix();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
||||
}
|
||||
// Belt-and-suspenders: also remove anything matching the slug prefix in case
|
||||
// a test threw before pushing its id into `created`.
|
||||
await cleanupByPrefix();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -24,6 +42,13 @@ describe("slugifyCategory", () => {
|
||||
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
||||
expect(slugifyCategory("Příprava")).toBe("priprava");
|
||||
});
|
||||
|
||||
it("collapses an all-symbol label to an empty slug", () => {
|
||||
// No alphanumerics survive → empty string. createPlanCategory's uniqueKey
|
||||
// then falls back to "kategorie" so the row is still creatable.
|
||||
expect(slugifyCategory("***")).toBe("");
|
||||
expect(slugifyCategory(" / ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan category service", () => {
|
||||
|
||||
@@ -23,4 +23,29 @@ describe("vatFromGross (VAT contained in a gross total)", () => {
|
||||
it("returns 0 for a zero amount", () => {
|
||||
expect(vatFromGross(0, 21)).toBe(0);
|
||||
});
|
||||
|
||||
it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => {
|
||||
// 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent
|
||||
// up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of-
|
||||
// round regression (which would yield 20.04) fails.
|
||||
expect(vatFromGross(115.5, 21)).toBe(20.05);
|
||||
// 95.70 @ 21% -> raw 16.609090... rounds up to 16.61.
|
||||
expect(vatFromGross(95.7, 21)).toBe(16.61);
|
||||
});
|
||||
|
||||
it("rounds the cent DOWN when below the half-cent", () => {
|
||||
// 100.40 @ 21% -> raw 17.424793... rounds down to 17.42.
|
||||
expect(vatFromGross(100.4, 21)).toBe(17.42);
|
||||
});
|
||||
|
||||
it("pins the result to the exact stored 2-dp value (not just close)", () => {
|
||||
// The DB column is Decimal(_, 2). vatFromGross must already return a value
|
||||
// with no third-decimal float drift. Strict equality + a 2-dp self-check,
|
||||
// NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail.
|
||||
const v = vatFromGross(1210, 21);
|
||||
expect(v).toBe(210); // exact, not ~210
|
||||
expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean
|
||||
// The real-world figure too: pinned exactly, replacing the toBeCloseTo above.
|
||||
expect(vatFromGross(22542.91, 21)).toBe(3912.41);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
|
||||
import {
|
||||
ReceiptItemSchema,
|
||||
CreateSupplierSchema,
|
||||
} from "../schemas/warehouse.schema";
|
||||
import { UpdateCompanySettingsSchema } from "../schemas/settings.schema";
|
||||
|
||||
describe("Zod NaN rejection", () => {
|
||||
// These schemas now build on the shared form-coercion helpers in
|
||||
// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …).
|
||||
// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers
|
||||
// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN
|
||||
// string. The original suite only asserted (b); these tests pin (a) as well.
|
||||
|
||||
describe("Zod form coercion + NaN rejection", () => {
|
||||
describe("CreateOrderSchema", () => {
|
||||
it("rejects NaN string in quantity", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
@@ -27,6 +40,21 @@ describe("Zod NaN rejection", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING quantity/unit_price to a number", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "2", unit_price: "100.5" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const item = result.data.items![0];
|
||||
expect(item.quantity).toBe(2);
|
||||
expect(typeof item.quantity).toBe("number");
|
||||
expect(item.unit_price).toBe(100.5);
|
||||
expect(typeof item.unit_price).toBe("number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
@@ -46,6 +74,18 @@ describe("Zod NaN rejection", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING vat_rate to a number", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "21",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.vat_rate).toBe(21);
|
||||
expect(typeof result.data.vat_rate).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects NaN string in item quantity", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
@@ -53,5 +93,215 @@ describe("Zod NaN rejection", () => {
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING item quantity/unit_price", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "3", unit_price: "250" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const item = result.data.items![0];
|
||||
expect(item.quantity).toBe(3);
|
||||
expect(item.unit_price).toBe(250);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The remaining schemas were the ones flagged as accepting NaN before the
|
||||
// shared helpers landed. Assert both the coercion and the rejection paths.
|
||||
|
||||
describe("CreateTripSchema", () => {
|
||||
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: "5",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "100",
|
||||
end_km: "150.5",
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.vehicle_id).toBe(5);
|
||||
expect(result.data.start_km).toBe(100);
|
||||
expect(result.data.end_km).toBe(150.5);
|
||||
expect(typeof result.data.start_km).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in start_km", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: 5,
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "not-a-number",
|
||||
end_km: 150,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN / non-positive string in vehicle_id (FK)", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: "abc",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: 100,
|
||||
end_km: 150,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateReceivedInvoiceSchema", () => {
|
||||
it("coerces numeric STRING month / year / amount to numbers", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: "6",
|
||||
year: "2026",
|
||||
amount: "1210.50",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.month).toBe(6);
|
||||
expect(result.data.year).toBe(2026);
|
||||
expect(result.data.amount).toBe(1210.5);
|
||||
expect(typeof result.data.amount).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in amount", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: 6,
|
||||
year: 2026,
|
||||
amount: "not-money",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range month (numberInRange 1-12)", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: 13,
|
||||
year: 2026,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN string in month", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: "bad",
|
||||
year: 2026,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehouse ReceiptItemSchema", () => {
|
||||
it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: "7",
|
||||
quantity: "12.5",
|
||||
unit_price: "99",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item_id).toBe(7);
|
||||
expect(result.data.quantity).toBe(12.5);
|
||||
expect(result.data.unit_price).toBe(99);
|
||||
expect(typeof result.data.quantity).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in quantity", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: 7,
|
||||
quantity: "lots",
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-positive quantity (positiveNumberFromForm)", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: 7,
|
||||
quantity: 0,
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN / non-positive string in item_id (FK)", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: "abc",
|
||||
quantity: 5,
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for the 2026-06-09 audit fix pass: the schema hardening
|
||||
// must NOT reject input the app legitimately sends.
|
||||
describe("schema hardening — does not reject previously-valid input", () => {
|
||||
it("settings: optional email fields accept an empty string (un-filled)", () => {
|
||||
const r = UpdateCompanySettingsSchema.safeParse({
|
||||
invoice_alert_email: "",
|
||||
leave_notify_email: "",
|
||||
smtp_from: "",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("settings: a valid email is accepted, a malformed one rejected", () => {
|
||||
expect(
|
||||
UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
|
||||
expect(
|
||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||
const base = {
|
||||
vehicle_id: 1,
|
||||
start_km: 100,
|
||||
end_km: 120,
|
||||
route_from: "A",
|
||||
route_to: "B",
|
||||
};
|
||||
// Plain date-only (from the date picker).
|
||||
const a = CreateTripSchema.safeParse({ ...base, trip_date: "2026-06-09" });
|
||||
expect(a.success).toBe(true);
|
||||
// The edit form re-submits what the API serialized for a @db.Date column
|
||||
// via the Date.toJSON override ("YYYY-MM-DDT00:00:00") — must still pass and
|
||||
// normalize to the date-only value.
|
||||
const b = UpdateTripSchema.safeParse({ trip_date: "2026-06-09T00:00:00" });
|
||||
expect(b.success).toBe(true);
|
||||
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||
// Genuinely malformed dates are still rejected.
|
||||
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,44 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env.test" });
|
||||
|
||||
const ENV_TEST_PATH = ".env.test";
|
||||
|
||||
// vitest.config.ts already loads .env.test with override:true; we reload here so
|
||||
// the setup file is self-contained when run directly.
|
||||
dotenv.config({ path: ENV_TEST_PATH });
|
||||
|
||||
/**
|
||||
* Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and
|
||||
* MUTATES it, so it must never run against a dev/production DB. We require the
|
||||
* configured database name to contain "test" (the committed `.env.test` points
|
||||
* at the throwaway `app_test`); anything else is a HARD FAILURE before any test
|
||||
* runs, so a stray DATABASE_URL can't silently corrupt dev/prod data.
|
||||
*/
|
||||
function extractDbName(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
// mysql://user:pass@host:3306/dbname?params → "dbname"
|
||||
const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, "");
|
||||
const dbName = afterAuthority.split("?")[0].split("/")[0];
|
||||
return dbName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
const dbName = extractDbName(dbUrl);
|
||||
|
||||
if (!dbUrl) {
|
||||
throw new Error(
|
||||
"[test-setup] DATABASE_URL is not set. The suite mutates a real database — " +
|
||||
`point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dbName == null || !/test/i.test(dbName)) {
|
||||
throw new Error(
|
||||
`[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` +
|
||||
`whose name does not contain "test". The suite mutates the database — point ` +
|
||||
`${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -627,19 +627,22 @@ describe("Item CRUD", () => {
|
||||
expect(body.data.name).toBe(`${N}item1_updated`);
|
||||
});
|
||||
|
||||
it("deactivates an item (soft delete)", async () => {
|
||||
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
|
||||
// The current API HARD-deletes the item — the route does
|
||||
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
|
||||
// test name reflects that real behavior so a future intended switch to
|
||||
// soft-delete would (correctly) make this test fail and prompt a review,
|
||||
// rather than the old "soft delete" name masking the mismatch.
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
// The current API hard-deletes the item (the route does
|
||||
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
|
||||
// Verify the row is gone rather than checking is_active.
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
// The row is actually gone — a subsequent GET 404s.
|
||||
const checkRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||
@@ -1030,6 +1033,124 @@ describe("Issue flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6b. FIFO ordering — the OLDEST batch is consumed first
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Issue FIFO ordering (oldest batch first)", () => {
|
||||
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
|
||||
// Build two batches for one item via two confirmed receipts.
|
||||
const itemRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/items",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `${N}item_fifo`, unit: "ks" },
|
||||
});
|
||||
const fifoItemId = itemRes.json().data.id as number;
|
||||
|
||||
const locA = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/locations",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { code: uniqueCode(), name: "fifo loc A" },
|
||||
})
|
||||
).json().data.id as number;
|
||||
const locB = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/locations",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { code: uniqueCode(), name: "fifo loc B" },
|
||||
})
|
||||
).json().data.id as number;
|
||||
|
||||
// Helper: create + confirm a receipt of `qty` at `locId`.
|
||||
const confirmReceipt = async (qty: number, locId: number) => {
|
||||
const r = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}fifo_receipt`,
|
||||
items: [
|
||||
{
|
||||
item_id: fifoItemId,
|
||||
quantity: qty,
|
||||
unit_price: 10,
|
||||
location_id: locId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const id = r.json().data.id as number;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/warehouse/receipts/${id}/confirm`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
};
|
||||
|
||||
// batchEarly is created FIRST (lower id); batchLate is created SECOND
|
||||
// (higher id). We then flip their received_at so the HIGHER-id batch is the
|
||||
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
|
||||
// by insertion id.
|
||||
await confirmReceipt(10, locA); // → batch with the lower id
|
||||
await confirmReceipt(10, locB); // → batch with the higher id
|
||||
|
||||
const batchesRaw = await prisma.sklad_batches.findMany({
|
||||
where: { item_id: fifoItemId },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(batchesRaw.length).toBe(2);
|
||||
const lowerId = batchesRaw[0]; // created first
|
||||
const higherId = batchesRaw[1]; // created second
|
||||
|
||||
// Make the HIGHER-id batch the OLDEST by received_at.
|
||||
await prisma.sklad_batches.update({
|
||||
where: { id: lowerId.id },
|
||||
data: { received_at: new Date("2021-01-01T00:00:00Z") },
|
||||
});
|
||||
await prisma.sklad_batches.update({
|
||||
where: { id: higherId.id },
|
||||
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
|
||||
});
|
||||
|
||||
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
|
||||
// (the higher-id one we back-dated), partially depleting it.
|
||||
const issueRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/issues",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
project_id: projectId,
|
||||
notes: `${N}fifo_issue`,
|
||||
items: [{ item_id: fifoItemId, quantity: 5 }],
|
||||
},
|
||||
});
|
||||
expect(issueRes.statusCode).toBe(201);
|
||||
const issueId = issueRes.json().data.id as number;
|
||||
const confirmRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(confirmRes.statusCode).toBe(200);
|
||||
|
||||
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
|
||||
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
|
||||
// wrongly sorted by id it would have consumed the lower-id batch instead.
|
||||
const oldest = await prisma.sklad_batches.findUnique({
|
||||
where: { id: higherId.id },
|
||||
});
|
||||
const newest = await prisma.sklad_batches.findUnique({
|
||||
where: { id: lowerId.id },
|
||||
});
|
||||
expect(Number(oldest!.quantity)).toBe(5);
|
||||
expect(Number(newest!.quantity)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Reservation flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,7 @@ const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
@@ -81,6 +82,15 @@ export default function AdminApp() {
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
{/*
|
||||
Auth gating is delegated to <AppShell>: every authenticated
|
||||
page is nested under this layout route, and AppShell
|
||||
redirects to /login when there is no user (per-page
|
||||
permission checks live in the pages themselves). NOTE: any
|
||||
NEW top-level route added OUTSIDE this <AppShell> wrapper
|
||||
(like `login`/`ui-kit` above) is PUBLIC by default — wrap it
|
||||
in AppShell or add its own guard if it needs auth.
|
||||
*/}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="odin" element={<Odin />} />
|
||||
@@ -131,6 +141,14 @@ export default function AdminApp() {
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route
|
||||
path="orders/issued/new"
|
||||
element={<IssuedOrderDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="orders/issued/:id"
|
||||
element={<IssuedOrderDetail />}
|
||||
/>
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
|
||||
@@ -12,7 +12,10 @@ export default function AlertContainer() {
|
||||
key={alert.id}
|
||||
open
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||
// Offset each stacked toast by its position in the list. Applied at
|
||||
// every breakpoint (not just xs) so larger screens don't fall back to
|
||||
// MUI's default bottom position and overlap with 3+ alerts.
|
||||
sx={{ bottom: 16 + index * 72 }}
|
||||
>
|
||||
<MuiAlert
|
||||
severity={alert.type}
|
||||
|
||||
@@ -116,7 +116,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
}
|
||||
return (
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
// Prefer the DB id; fall back to a project_id+index composite so
|
||||
// logs without an id (e.g. active/unsaved rows) still get a key
|
||||
// that's more stable than a bare index.
|
||||
key={log.id ?? `${log.project_id}-${i}`}
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} ${
|
||||
durationValid
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function BulkAttendanceModal({
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
{users.length > 0 && form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
|
||||
@@ -19,6 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Logged to the console only — the app has no client telemetry/error
|
||||
// reporting service today. This is the hook to forward to one (Sentry,
|
||||
// etc.) if/when it's added.
|
||||
console.error("ErrorBoundary caught:", error, info);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -7,7 +7,22 @@ import { useTheme } from "@mui/material/styles";
|
||||
import { Modal, Button, TextField, Field } from "../ui";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
|
||||
// string while editing (so a field can be cleared — empty renders fine in a
|
||||
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
||||
// being handed to onGenerate (the parent posts them verbatim).
|
||||
interface ConfirmationItem {
|
||||
description: string;
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: string | number;
|
||||
}
|
||||
|
||||
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
||||
// handleEditGenerate). Distinct from the editable ConfirmationItem.
|
||||
interface GeneratedItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
@@ -22,7 +37,7 @@ interface OrderConfirmationModalProps {
|
||||
onGenerate: (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
items?: ConfirmationItem[],
|
||||
items?: GeneratedItem[],
|
||||
) => Promise<void>;
|
||||
initialItems: ConfirmationItem[];
|
||||
orderNumber: string;
|
||||
@@ -48,31 +63,57 @@ export default function OrderConfirmationModal({
|
||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// The modal is permanently mounted and merely toggled via `isOpen`, so its
|
||||
// local state survives between opens and the `useState(initialItems)` snapshot
|
||||
// goes stale. Re-seed everything from the current props each time the modal
|
||||
// (re)opens so a fresh open always reflects the latest order data and starts
|
||||
// on the "choose" step.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setStep("choose");
|
||||
setLang("cs");
|
||||
setApplyVatState(applyVat);
|
||||
setItems(initialItems);
|
||||
// initialItems/applyVat are captured at open time; intentionally not in the
|
||||
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleUseExisting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, undefined);
|
||||
// Only close on success — a generation error must keep the modal open.
|
||||
onClose();
|
||||
} 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);
|
||||
// Coerce the raw typed strings back to numbers so an empty/partial field
|
||||
// never reaches the server as NaN (the parent posts these verbatim).
|
||||
const coercedItems: GeneratedItem[] = items.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit,
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: it.is_included_in_total,
|
||||
vat_rate: Number(it.vat_rate) || 0,
|
||||
}));
|
||||
await onGenerate(lang, applyVatState, coercedItems);
|
||||
// Only close on success — on error keep the user's edited items intact.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Chyba při generování potvrzení:", err);
|
||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStep("choose");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,9 +298,9 @@ export default function OrderConfirmationModal({
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
updateItem(i, "quantity", e.target.value)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
@@ -271,18 +312,18 @@ export default function OrderConfirmationModal({
|
||||
<TextField
|
||||
label="Cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit_price", Number(e.target.value) || 0)
|
||||
updateItem(i, "unit_price", e.target.value)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
@@ -334,15 +375,11 @@ export default function OrderConfirmationModal({
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
updateItem(i, "quantity", e.target.value)
|
||||
}
|
||||
sx={{ width: 90 }}
|
||||
sx={{ width: 112 }}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
</td>
|
||||
@@ -358,13 +395,9 @@ export default function OrderConfirmationModal({
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
updateItem(i, "unit_price", e.target.value)
|
||||
}
|
||||
sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
@@ -373,13 +406,9 @@ export default function OrderConfirmationModal({
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
|
||||
@@ -145,6 +145,10 @@ export default function PlanCategoriesModal({
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<TextField
|
||||
// Uncontrolled defaultValue won't update if the label changes
|
||||
// externally (e.g. another tab renames it). Keying on c.label
|
||||
// remounts the input so it re-seeds, mirroring the color input.
|
||||
key={c.label}
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
|
||||
@@ -28,6 +28,35 @@ interface Project {
|
||||
project_number?: string;
|
||||
}
|
||||
|
||||
/** Fields shared by every plan-record save payload the modal emits. */
|
||||
interface PlanRecordFields {
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Body for creating a plan entry (a single day or a multi-day range). */
|
||||
export interface PlanEntryCreateBody extends PlanRecordFields {
|
||||
user_id: number;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
/** Body for updating an existing plan entry (the entry id is passed separately). */
|
||||
export interface PlanEntryUpdateBody extends PlanRecordFields {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
/** Body for creating a single-day override. */
|
||||
export interface PlanOverrideCreateBody extends PlanRecordFields {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
}
|
||||
|
||||
/** Body for updating an existing override (the override id is passed separately). */
|
||||
export type PlanOverrideUpdateBody = PlanRecordFields;
|
||||
|
||||
export type PlanCellModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
@@ -74,11 +103,11 @@ interface Props {
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
onClose: () => void;
|
||||
onSaveEntry: (body: any) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
||||
onSaveEntry: (body: PlanEntryCreateBody) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: PlanEntryUpdateBody) => Promise<void>;
|
||||
onDeleteEntry: (id: number) => Promise<void>;
|
||||
onSaveOverride: (body: any) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
||||
onSaveOverride: (body: PlanOverrideCreateBody) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: PlanOverrideUpdateBody) => Promise<void>;
|
||||
onDeleteOverride: (id: number) => Promise<void>;
|
||||
onCreateOverrideFromRange: (
|
||||
entryId: number,
|
||||
@@ -128,47 +157,32 @@ export default function PlanCellModal(props: Props) {
|
||||
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" | "create-override" | "edit-range" |
|
||||
// "edit-override" but the parameter type is the full union, so we narrow
|
||||
// explicitly here.
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "create-override" &&
|
||||
mode.kind !== "edit-range" &&
|
||||
mode.kind !== "edit-override"
|
||||
) {
|
||||
return null;
|
||||
interface EditFormInitial {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
is_range: boolean;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// An override is a single-day record — both editing one ("edit-override")
|
||||
// and creating one ("create-override") lock the date and hide the range UI.
|
||||
const isOverride =
|
||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
|
||||
const initial = (() => {
|
||||
/**
|
||||
* Initial form values for every mode kind. Unhandled kinds get a harmless
|
||||
* default (EditForm renders null for them — but only AFTER its hooks have run).
|
||||
* Keeping this a plain function (not an inline IIFE with early returns) lets
|
||||
* EditForm call its useState hooks unconditionally, satisfying the Rules of
|
||||
* Hooks.
|
||||
*/
|
||||
function computeEditFormInitial(
|
||||
mode: PlanCellModalMode,
|
||||
activeCategories: { key: string }[],
|
||||
): EditFormInitial {
|
||||
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: null as number | null,
|
||||
project_id: 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")
|
||||
@@ -187,7 +201,7 @@ function EditForm(props: Props) {
|
||||
note: mode.range.note,
|
||||
};
|
||||
}
|
||||
// mode.kind === "edit-override" (narrowed above)
|
||||
if (mode.kind === "edit-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
@@ -196,8 +210,41 @@ function EditForm(props: Props) {
|
||||
category: mode.cell.category,
|
||||
note: mode.cell.note,
|
||||
};
|
||||
})();
|
||||
}
|
||||
// Unhandled kinds: harmless defaults (EditForm returns null after its hooks).
|
||||
return {
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
is_range: false,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
|
||||
function EditForm(props: Props) {
|
||||
const {
|
||||
mode,
|
||||
onClose,
|
||||
onSaveEntry,
|
||||
onUpdateEntry,
|
||||
onDeleteEntry,
|
||||
onSaveOverride,
|
||||
onUpdateOverride,
|
||||
onDeleteOverride,
|
||||
projects,
|
||||
} = props;
|
||||
// An override is a single-day record — both editing one ("edit-override")
|
||||
// and creating one ("create-override") lock the date and hide the range UI.
|
||||
const isOverride =
|
||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
// Computed for ALL mode kinds (the helper has a fallback) so the hooks below
|
||||
// are unconditional.
|
||||
const initial = computeEditFormInitial(mode, activeCategories);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||
const [isRange, setIsRange] = useState(initial.is_range);
|
||||
@@ -205,6 +252,18 @@ function EditForm(props: Props) {
|
||||
const [category, setCategory] = useState(initial.category);
|
||||
const [note, setNote] = useState(initial.note);
|
||||
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site only ever
|
||||
// passes the four editable kinds; we guard AFTER all hooks so the hook order
|
||||
// is identical on every render (Rules of Hooks).
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "create-override" &&
|
||||
mode.kind !== "edit-range" &&
|
||||
mode.kind !== "edit-override"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title =
|
||||
mode.kind === "create"
|
||||
? "Nový záznam plánu"
|
||||
|
||||
@@ -466,9 +466,23 @@ export default function PlanGrid({
|
||||
}: Props) {
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
// Index projects by id once per projects change instead of a linear
|
||||
// projects.find() inside the per-cell render loop (which was
|
||||
// O(days * users * cells * projects)). Hooks must run before the early
|
||||
// return below, so this is computed unconditionally.
|
||||
const projectMap = useMemo(() => {
|
||||
const m = new Map<number, Project>();
|
||||
for (const p of projects) m.set(p.id, p);
|
||||
return m;
|
||||
}, [projects]);
|
||||
// Memoize the day list so it isn't rebuilt on every render (only when the
|
||||
// visible range changes). Stable identity also keeps the row map cheap.
|
||||
const days = useMemo(
|
||||
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
||||
[data?.date_from, data?.date_to],
|
||||
);
|
||||
|
||||
if (!data) return <LoadingState />;
|
||||
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
|
||||
@@ -610,12 +624,9 @@ export default function PlanGrid({
|
||||
cell={c}
|
||||
project={
|
||||
c.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === c.project_id,
|
||||
) ?? null)
|
||||
? (projectMap.get(c.project_id) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={planCategoryLabel(
|
||||
c.category,
|
||||
catMap,
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { Project } from "../lib/queries/projects";
|
||||
interface Props {
|
||||
cell: ResolvedCell | null;
|
||||
project: Project | null;
|
||||
readonly?: boolean;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
// Purely presentational — renders the category/project chips and note for a
|
||||
// single resolved plan cell. It has no interactive handlers of its own (the
|
||||
// click target is the cell <button> in PlanGrid, which already applies the
|
||||
// read-only styling/behaviour), so there is no read-only state to gate here.
|
||||
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
|
||||
|
||||
@@ -250,6 +250,11 @@ export default function ProjectFileManager({
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isCancelling = useRef(false);
|
||||
// dragenter/dragleave fire on every child boundary crossed during a drag.
|
||||
// Counting enter/leave events keeps the overlay steady instead of flickering
|
||||
// as the cursor moves over nested elements; the overlay only hides when the
|
||||
// depth returns to 0 (the cursor truly left the drop zone).
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
|
||||
@@ -333,7 +338,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
errorMsg = data.error || "Chyba při nahrávání";
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: upload failed", e);
|
||||
errorMsg = "Chyba připojení";
|
||||
}
|
||||
}
|
||||
@@ -362,21 +368,32 @@ export default function ProjectFileManager({
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
if (!canManage) return;
|
||||
handleUpload(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (canManage) {
|
||||
if (!canManage) return;
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
// Must preventDefault on dragover too, otherwise the browser rejects the
|
||||
// drop. The overlay state is driven by enter/leave (see dragDepth).
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canManage) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) {
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
@@ -405,7 +422,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: create folder failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setCreatingFolder(false);
|
||||
@@ -435,7 +453,8 @@ export default function ProjectFileManager({
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: download failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
};
|
||||
@@ -468,7 +487,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se smazat");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: delete failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -505,7 +525,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: rename failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setRenamingItem(null);
|
||||
@@ -887,6 +908,7 @@ export default function ProjectFileManager({
|
||||
{/* Drop zone + table */}
|
||||
<Box
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
sx={{
|
||||
|
||||
@@ -88,7 +88,9 @@ export default function RichEditor({
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(content: string, _delta: any, source: string) => {
|
||||
// `_delta` is react-quill-new's loosely-typed Delta object; we never read
|
||||
// it, so `unknown` is enough and avoids an `any` escape hatch.
|
||||
(content: string, _delta: unknown, source: string) => {
|
||||
if (source !== "user") return;
|
||||
if (content === lastValueRef.current) return;
|
||||
lastValueRef.current = content;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -17,8 +18,6 @@ import {
|
||||
formatDate,
|
||||
} from "../utils/attendanceHelpers";
|
||||
|
||||
let _logKeyCounter = 0;
|
||||
|
||||
// ---------- Shared types ----------
|
||||
|
||||
export interface ShiftFormData {
|
||||
@@ -226,6 +225,11 @@ export default function ShiftFormModal({
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
// Per-instance counter for stable React keys on newly-added project rows.
|
||||
// (Was a module-level mutable `let`, which is shared across every modal
|
||||
// instance and fragile under StrictMode / concurrent instances.)
|
||||
const logKeyCounter = useRef(0);
|
||||
|
||||
const updateField = (field: keyof ShiftFormData, value: string | number) => {
|
||||
setForm({ ...form, [field]: value });
|
||||
};
|
||||
@@ -244,7 +248,7 @@ export default function ShiftFormModal({
|
||||
setProjectLogs([
|
||||
...projectLogs,
|
||||
{
|
||||
_key: `log-${++_logKeyCounter}`,
|
||||
_key: `log-${++logKeyCounter.current}`,
|
||||
project_id: "",
|
||||
hours: "",
|
||||
minutes: "",
|
||||
@@ -327,7 +331,9 @@ export default function ShiftFormModal({
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
// Empty input -> parseFloat returns NaN, which serializes to an
|
||||
// invalid value; fall back to 0 like the Number(...)||0 pattern.
|
||||
updateField("leave_hours", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
@@ -431,7 +437,10 @@ export default function ShiftFormModal({
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
// Stable key: client-added rows carry `_key`; server-loaded rows
|
||||
// carry a DB `id`. Fall back to the index only when neither is
|
||||
// present (shouldn't happen, but avoids a key collision).
|
||||
key={log._key ?? (log.id != null ? `id-${log.id}` : i)}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Intentional no-op: this component is still mounted by AppShell.tsx as a
|
||||
// placeholder for a future keyboard-shortcuts help overlay. It renders nothing
|
||||
// today. Keep the harmless `null` return (and the AppShell import) until the
|
||||
// overlay is actually built — do not delete the file while AppShell references it.
|
||||
export default function ShortcutsHelp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
@@ -121,6 +121,7 @@ const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const kpiCards = buildKpiCards(dashData);
|
||||
if (kpiCards.length === 0) {
|
||||
return null;
|
||||
@@ -129,8 +130,8 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return (
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -125,6 +126,8 @@ export default function DashProfile({
|
||||
}: DashProfileProps) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
|
||||
@@ -156,21 +159,30 @@ export default function DashProfile({
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
if (dataToSave.new_password && !dataToSave.current_password) {
|
||||
if (formData.new_password && !formData.current_password) {
|
||||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||||
return;
|
||||
}
|
||||
if (dataToSave.current_password && !dataToSave.new_password) {
|
||||
if (formData.current_password && !formData.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;
|
||||
// Build the payload with the password fields optional so empty ones can be
|
||||
// omitted (Zod rejects ""), without resorting to `as any` deletes.
|
||||
const dataToSave: Partial<
|
||||
Pick<ProfileFormData, "current_password" | "new_password">
|
||||
> &
|
||||
Omit<ProfileFormData, "current_password" | "new_password"> = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
};
|
||||
if (formData.current_password)
|
||||
dataToSave.current_password = formData.current_password;
|
||||
if (formData.new_password) dataToSave.new_password = formData.new_password;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||
@@ -185,13 +197,21 @@ export default function DashProfile({
|
||||
email: dataToSave.email,
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||
});
|
||||
// Refresh anything keyed on the current user's data so stale views
|
||||
// (dashboard widgets, user lists) pick up the edited profile.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setShowModal(false);
|
||||
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
||||
// before the success toast appears, so the toast doesn't flash over the
|
||||
// still-fading dialog.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success("Profil byl upraven");
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se uložit profil");
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("DashProfile: uložení profilu selhalo", err);
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
};
|
||||
@@ -216,8 +236,8 @@ export default function DashProfile({
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
@@ -16,6 +17,14 @@ interface Vehicle {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Standard { success, data, error, message } envelope returned by the API.
|
||||
interface ApiResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TripForm {
|
||||
vehicle_id: string;
|
||||
trip_date: string;
|
||||
@@ -61,6 +70,8 @@ export default function DashQuickActions({
|
||||
}: DashQuickActionsProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const [showTripModal, setShowTripModal] = useState(false);
|
||||
const [tripSubmitting, setTripSubmitting] = useState(false);
|
||||
@@ -93,16 +104,17 @@ export default function DashQuickActions({
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`);
|
||||
const result = await response.json();
|
||||
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
|
||||
await response.json();
|
||||
if (result.success) {
|
||||
setTripVehicles(
|
||||
Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.vehicles || [],
|
||||
: (result.data?.vehicles ?? []),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// vozidla se nenacetla
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,12 +125,16 @@ export default function DashQuickActions({
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
|
||||
const result: ApiResult<{ last_km: number | string }> =
|
||||
await response.json();
|
||||
if (result.success && result.data) {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: String(result.data!.last_km ?? ""),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// last_km se nenacetlo
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,7 +155,7 @@ export default function DashQuickActions({
|
||||
if (
|
||||
tripForm.start_km &&
|
||||
tripForm.end_km &&
|
||||
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
|
||||
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
|
||||
) {
|
||||
errs.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
@@ -161,14 +177,19 @@ export default function DashQuickActions({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(tripForm),
|
||||
});
|
||||
const result = await response.json();
|
||||
const result: ApiResult<unknown> = await response.json();
|
||||
if (result.success) {
|
||||
// A new trip changes the trip list and the dashboard vehicle widgets —
|
||||
// invalidate the broad domains (prefix-matching covers sub-queries).
|
||||
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
setShowTripModal(false);
|
||||
alert.success(result.message);
|
||||
alert.success(result.message ?? "Jízda uložena");
|
||||
} else {
|
||||
alert.error(result.error);
|
||||
alert.error(result.error ?? "Uložení jízdy selhalo");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: uložení jízdy selhalo", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTripSubmitting(false);
|
||||
@@ -176,8 +197,8 @@ export default function DashQuickActions({
|
||||
};
|
||||
|
||||
const tripDistance = (): number => {
|
||||
const s = parseInt(tripForm.start_km) || 0;
|
||||
const e = parseInt(tripForm.end_km) || 0;
|
||||
const s = parseInt(tripForm.start_km, 10) || 0;
|
||||
const e = parseInt(tripForm.end_km, 10) || 0;
|
||||
return e > s ? e - s : 0;
|
||||
};
|
||||
|
||||
@@ -294,8 +315,8 @@ export default function DashQuickActions({
|
||||
<>
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -69,6 +69,7 @@ function getDeviceIcon(iconType?: string) {
|
||||
export default function DashSessions() {
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const { data: sessions = [], isPending: sessionsLoading } =
|
||||
useQuery(sessionsOptions());
|
||||
@@ -128,8 +129,8 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
|
||||
@@ -125,7 +125,19 @@ export default function OdinSidebar({
|
||||
return (
|
||||
<Box
|
||||
key={conv.id}
|
||||
role="button"
|
||||
tabIndex={busy ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => !busy && onSelect(conv.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate the row on Enter/Space like a native button. The
|
||||
// nested kebab IconButton stays a real <button> and handles its
|
||||
// own keys (its onClick stopPropagation prevents row selection).
|
||||
if (!busy && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onSelect(conv.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -139,6 +151,7 @@ export default function OdinSidebar({
|
||||
bgcolor: isActive ? "action.selected" : "action.hover",
|
||||
},
|
||||
"&:hover .odin-conv-menu": { opacity: 1 },
|
||||
"&:focus-visible .odin-conv-menu": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
||||
@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import useDebounce from "../../hooks/useDebounce";
|
||||
import {
|
||||
warehouseItemListOptions,
|
||||
type WarehouseItem,
|
||||
@@ -26,9 +27,11 @@ export default function ItemPicker({
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
// Debounce the search so the list query doesn't fire on every keystroke.
|
||||
const debouncedSearch = useDebounce(inputValue, 300);
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
warehouseItemListOptions({ search: debouncedSearch, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
@@ -16,7 +17,7 @@ export default function ReservationPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: ReservationPickerProps) {
|
||||
const { data: result } = useQuery(
|
||||
const { data: result, isFetching } = useQuery(
|
||||
warehouseReservationListOptions({
|
||||
item_id: itemId ?? undefined,
|
||||
project_id: projectId ?? undefined,
|
||||
@@ -27,6 +28,22 @@ export default function ReservationPicker({
|
||||
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
// When item/project change, the loaded reservation list changes. If the
|
||||
// currently-selected reservation is no longer available, clear it and notify
|
||||
// the parent so it doesn't keep holding a stale reservationId. Only act once
|
||||
// the query has settled (result present, not fetching) to avoid resetting
|
||||
// while the new list is still loading.
|
||||
useEffect(() => {
|
||||
if (
|
||||
value != null &&
|
||||
result !== undefined &&
|
||||
!isFetching &&
|
||||
!reservations.some((r) => r.id === value)
|
||||
) {
|
||||
onChange(null, 0);
|
||||
}
|
||||
}, [value, result, isFetching, reservations, onChange]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
@@ -38,7 +55,7 @@ export default function ReservationPicker({
|
||||
const reservationId = Number(val);
|
||||
const reservation = reservations.find((r) => r.id === reservationId);
|
||||
if (reservation) {
|
||||
onChange(reservationId, Number(reservation.remaining_qty));
|
||||
onChange(reservationId, reservation.remaining_qty);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -46,7 +63,7 @@ export default function ReservationPicker({
|
||||
{reservations.map((r) => (
|
||||
<MenuItem key={r.id} value={String(r.id)}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
{r.remaining_qty} {r.item?.unit ?? "ks"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -122,7 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
},
|
||||
[],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||
@@ -315,6 +315,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [getAccessTokenFn]);
|
||||
|
||||
// NOTE: this is a SECOND 401-refresh-and-retry path that parallels
|
||||
// `apiFetch` in src/admin/utils/api.ts (which most pages use via the query
|
||||
// helpers). The two diverge: `apiRequest` ignores AbortSignals and does NOT
|
||||
// set the session-expired flag, whereas `apiFetch` dedupes concurrent
|
||||
// refreshes, honors abort, and surfaces the 499 session-expired sentinel.
|
||||
// Consolidating onto `apiFetch` is a tracked follow-up but is deferred here
|
||||
// because it touches the core auth/refresh flow (high regression risk). If
|
||||
// you change refresh/retry semantics in one, mirror it in the other.
|
||||
const apiRequest = useCallback(
|
||||
async (endpoint: string, options: RequestInit = {}) => {
|
||||
let token = getAccessTokenFn();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// NOTE: this hook locks `document.body.style.overflow` only. The app layout
|
||||
// scrolls on `<html>` (GlobalStyles sets `html { overflow-x: hidden }`, which
|
||||
// makes `<html>` the scroll container), so a body-only lock may not fully
|
||||
// prevent background scroll. The kit's Modal/ConfirmDialog use
|
||||
// `useDialogScrollLock`, which correctly locks `<html>`. This hook is retained
|
||||
// only for the two non-kit in-page edit modals that still call it
|
||||
// (AttendanceAdmin, WarehouseItemDetail); prefer `useDialogScrollLock` for new
|
||||
// dialogs. Behavior intentionally left unchanged to avoid regressing those
|
||||
// pages — see REVIEW_FINDINGS (useModalLock LOW).
|
||||
let activeLocks = 0;
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
|
||||
@@ -19,9 +19,18 @@ interface PaginatedResult<T> {
|
||||
* Wrapper around useQuery for paginated list endpoints.
|
||||
* Accepts the return value of queryOptions() from lib/queries/*
|
||||
* and extracts items + pagination from the response.
|
||||
*
|
||||
* `options` is intentionally `any`: the query helpers pass a `queryOptions(...)`
|
||||
* object whose `queryKey` is a concrete mutable tuple, and TanStack's `enabled`
|
||||
* predicate is contravariant in the key type — pinning the key generic here
|
||||
* (even via an inferred `TKey`) fails to unify for several callers. The data
|
||||
* shape we rely on (`PaginatedResult<T>`) is enforced by the cast below, so the
|
||||
* loss of input typing on `options` is contained to this thin wrapper.
|
||||
*/
|
||||
export function usePaginatedQuery<T>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function usePaginatedQuery<T>(options: any) {
|
||||
options: any,
|
||||
) {
|
||||
const query = useQuery({
|
||||
...options,
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -59,6 +59,49 @@ async function apiCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Request-body shapes for the plan mutations. The server validates the full
|
||||
// schema; these capture the fields the optimistic patches read.
|
||||
export interface PlanEntryBody {
|
||||
user_id: number;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface PlanEntryPatchBody {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
project_id?: number | null;
|
||||
category?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PlanOverrideBody {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface PlanOverridePatchBody {
|
||||
project_id?: number | null;
|
||||
category?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateBody {
|
||||
user_ids: number[];
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
include_weekends: boolean;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface UsePlanWorkArgs {
|
||||
initialDate?: Date;
|
||||
canEdit: boolean;
|
||||
@@ -88,10 +131,24 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
|
||||
const gridQuery = useQuery({
|
||||
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
queryFn: async (): Promise<GridData> => {
|
||||
const res = await 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)),
|
||||
);
|
||||
if (!res.ok) {
|
||||
let message = "Chyba serveru";
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody && typeof errBody.error === "string")
|
||||
message = errBody.error;
|
||||
} catch {
|
||||
// body wasn't JSON; use default
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const body = (await res.json()) as { data: GridData };
|
||||
return body.data;
|
||||
},
|
||||
// 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.
|
||||
@@ -232,20 +289,54 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
//
|
||||
// Optimistic-update pattern (idiomatic TanStack Query):
|
||||
// onMutate → apply the optimistic grid patch and RETURN the rollback
|
||||
// snapshot as this invocation's context (concurrency-safe:
|
||||
// each in-flight mutation owns its own context object, so two
|
||||
// concurrent mutations can't clobber each other's snapshot —
|
||||
// the previous `(mutation as any)._rolled = …` stash could).
|
||||
// onError → restore the cells captured in onMutate's context.
|
||||
// onSettled → invalidate the domain so the server (source of truth)
|
||||
// reconciles the patch on both success AND error.
|
||||
// The visible UX is unchanged: the new/updated cell still appears
|
||||
// immediately and the broad invalidate still refetches the grid.
|
||||
|
||||
const createEntry = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
type CellRollback = {
|
||||
key: readonly unknown[];
|
||||
userId: number;
|
||||
rolled: Record<string, ResolvedCell[]> | null;
|
||||
} | null;
|
||||
|
||||
// Restore the cells a patch overwrote, from an onMutate-returned context.
|
||||
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||
function rollbackCells(ctx: CellRollback | undefined) {
|
||||
if (!ctx || !ctx.rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cells] of Object.entries(ctx.rolled)) {
|
||||
userNext[date] = cells;
|
||||
}
|
||||
qc.setQueryData(ctx.key, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [ctx.userId]: userNext },
|
||||
});
|
||||
}
|
||||
|
||||
const createEntry = useMutation<unknown, Error, PlanEntryBody, CellRollback>({
|
||||
mutationFn: (body: PlanEntryBody) =>
|
||||
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.
|
||||
onMutate: (body): CellRollback => {
|
||||
// Patch the visible grid with the new entry. We don't have the
|
||||
// server-assigned id yet (entryId stays null) — the refetch fills it
|
||||
// within ~200ms; PlanGrid only uses entryId for the edit flow.
|
||||
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, (prev) => [
|
||||
makeEntryCell({
|
||||
userId: body.user_id,
|
||||
@@ -255,40 +346,35 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
note: body.note,
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
entryId: null,
|
||||
}),
|
||||
...prev,
|
||||
]);
|
||||
// Stash the rollback on the mutation object so PlanWork can call
|
||||
// it from onError.
|
||||
(createEntry as any)._rolled = rolled;
|
||||
invalidate();
|
||||
return { key: currentGridKey, userId: body.user_id, rolled };
|
||||
},
|
||||
onError: (_err, _body, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const updateEntry = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
const updateEntry = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; body: PlanEntryPatchBody; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, body, force }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: ({ id, body }): CellRollback => {
|
||||
// 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) {
|
||||
if (!body.date_from || !body.date_to) return null;
|
||||
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
|
||||
@@ -306,12 +392,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
if (ownerUserId !== null) break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
ownerUserId,
|
||||
days,
|
||||
(prev) => {
|
||||
if (ownerUserId === null) return null;
|
||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => {
|
||||
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||
const updated = makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
@@ -322,68 +404,76 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
: body.project_id,
|
||||
category: body.category ?? existing?.category ?? "work",
|
||||
note: body.note ?? existing?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
rangeFrom: body.date_from!,
|
||||
rangeTo: body.date_to!,
|
||||
entryId: id,
|
||||
});
|
||||
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||
});
|
||||
return { key: currentGridKey, userId: ownerUserId, rolled };
|
||||
},
|
||||
);
|
||||
(updateEntry as any)._rolled = rolled;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const deleteEntry = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
const deleteEntry = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, force }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
// 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) {
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
let range: { from: string; to: string } | null = null;
|
||||
let entryRange: { from: string; to: string } | null = null;
|
||||
for (const cells of Object.values(byDate)) {
|
||||
const hit = cells.find(
|
||||
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||
);
|
||||
if (hit) {
|
||||
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||
entryRange = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (range) {
|
||||
const days = eachDay(range.from, range.to);
|
||||
if (entryRange) {
|
||||
const days = eachDay(entryRange.from, entryRange.to);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
break;
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const createOverride = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
const createOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
PlanOverrideBody,
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: (body: PlanOverrideBody) =>
|
||||
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;
|
||||
onMutate: (vars): CellRollback => {
|
||||
// overrideId stays null optimistically; the refetch fills the real id.
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
vars.user_id,
|
||||
@@ -395,36 +485,34 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
projectId: vars.project_id ?? null,
|
||||
category: vars.category,
|
||||
note: vars.note,
|
||||
overrideId: id,
|
||||
overrideId: null,
|
||||
}),
|
||||
...prev,
|
||||
],
|
||||
);
|
||||
(createOverride as any)._rolled = rolled;
|
||||
invalidate();
|
||||
return { key: currentGridKey, userId: vars.user_id, rolled };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const updateOverride = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
const updateOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; body: PlanOverridePatchBody; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, body, force }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
// 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) {
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
@@ -440,8 +528,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
date,
|
||||
projectId:
|
||||
vars.body.project_id ?? existing?.project_id ?? null,
|
||||
category:
|
||||
vars.body.category ?? existing?.category ?? "work",
|
||||
category: vars.body.category ?? existing?.category ?? "work",
|
||||
note: vars.body.note ?? existing?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
});
|
||||
@@ -451,24 +538,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
];
|
||||
},
|
||||
);
|
||||
(updateOverride as any)._rolled = rolled;
|
||||
break;
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const deleteOverride = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
const deleteOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, force }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
@@ -478,18 +570,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
[date],
|
||||
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||
);
|
||||
(deleteOverride as any)._rolled = rolled;
|
||||
break;
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const bulkCreate = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
const bulkCreate = useMutation<unknown, Error, BulkCreateBody>({
|
||||
mutationFn: (body: BulkCreateBody) =>
|
||||
apiCall("/api/admin/plan/entries/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
@@ -502,29 +594,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
};
|
||||
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, cells] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cells;
|
||||
}
|
||||
qc.setQueryData(currentGridKey, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
stash._rolled = null;
|
||||
// Rollback is now handled idiomatically inside each mutation's `onError`
|
||||
// from its `onMutate`-returned context (concurrency-safe per-invocation
|
||||
// snapshot). This is kept as a no-op for the existing call sites in
|
||||
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
||||
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||
// restored the patched cells, so there is nothing left to do here.
|
||||
|
||||
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||
/* no-op — see onMutate/onError above */
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,16 +4,22 @@ 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.
|
||||
* SSR-safe: the lazy initializer reads the live matchMedia value on the client
|
||||
* (and falls back to `false` when `window`/`matchMedia` is unavailable, e.g.
|
||||
* during SSR), so a reduced-motion user does NOT get one frame of animation
|
||||
* before the effect reconciles. The effect then only listens for changes.
|
||||
*/
|
||||
export default function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const [reduced, setReduced] = useState(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
// Reconcile in case the value changed between the lazy init and mount
|
||||
// (or after SSR hydration).
|
||||
setReduced(mq.matches);
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
|
||||
112
src/admin/lib/documentStatus.ts
Normal file
112
src/admin/lib/documentStatus.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Single source of truth for document status labels (Czech) + chip colors
|
||||
* (MUI semantic) across the Offers / Invoices / Orders modules.
|
||||
*
|
||||
* Previously every list AND detail page defined its own `STATUS_LABELS` +
|
||||
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
|
||||
* was `warning` in the list but `info` in the detail. This module consolidates
|
||||
* them so the chip color/label for a given status is identical everywhere.
|
||||
*
|
||||
* Status KEYS are the ACTUAL values stored in the database and are kept AS-IS
|
||||
* (e.g. customer orders are still keyed in Czech: `prijata` / `v_realizaci` /
|
||||
* …). A Czech→English rename is a separate, deferred DB migration.
|
||||
*
|
||||
* The color union matches the kit `StatusChip` (src/admin/ui/StatusChip.tsx).
|
||||
*/
|
||||
|
||||
export type StatusColor = "default" | "info" | "warning" | "success" | "error";
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string;
|
||||
color: StatusColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* OFFER / quotations. Stored statuses are `active` (default) / `ordered` /
|
||||
* `invalidated` (see offers.service.ts). Labels are taken from the Offers
|
||||
* filter tabs; colors are synthesized to match the list row tints
|
||||
* (active = neutral-active info, ordered = success, invalidated = error).
|
||||
* `expired` is a defensive entry — it is a FRONT-END derived state (computed
|
||||
* from `valid_until`), not a stored status, but mapping it keeps any future
|
||||
* stored value rendering consistently (warning, matching the expired row wash).
|
||||
*/
|
||||
export const OFFER_STATUS: Record<string, StatusMeta> = {
|
||||
draft: { label: "Koncept", color: "default" },
|
||||
active: { label: "Aktivní", color: "info" },
|
||||
ordered: { label: "Objednaná", color: "success" },
|
||||
invalidated: { label: "Zneplatněná", color: "error" },
|
||||
expired: { label: "Po platnosti", color: "warning" },
|
||||
};
|
||||
|
||||
/**
|
||||
* CUSTOMER ORDER / objednávka přijatá (orders). Czech-keyed (legacy-PHP
|
||||
* carryover; rename is a deferred migration).
|
||||
*/
|
||||
export const ORDER_STATUS: Record<string, StatusMeta> = {
|
||||
prijata: { label: "Přijatá", color: "info" },
|
||||
v_realizaci: { label: "V realizaci", color: "warning" },
|
||||
dokoncena: { label: "Dokončená", color: "success" },
|
||||
stornovana: { label: "Stornována", color: "error" },
|
||||
};
|
||||
|
||||
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
|
||||
export const ISSUED_ORDER_STATUS: Record<string, StatusMeta> = {
|
||||
draft: { label: "Koncept", color: "default" },
|
||||
sent: { label: "Odeslaná", color: "info" },
|
||||
confirmed: { label: "Potvrzená", color: "warning" },
|
||||
completed: { label: "Dokončená", color: "success" },
|
||||
cancelled: { label: "Stornovaná", color: "error" },
|
||||
};
|
||||
|
||||
/**
|
||||
* ISSUED INVOICE (invoices). `issued` resolves to `warning` (payment-pending
|
||||
* = warning, NOT info) — this fixes the list↔detail color drift.
|
||||
*/
|
||||
export const INVOICE_STATUS: Record<string, StatusMeta> = {
|
||||
draft: { label: "Koncept", color: "default" },
|
||||
issued: { label: "Vystavena", color: "warning" },
|
||||
overdue: { label: "Po splatnosti", color: "error" },
|
||||
paid: { label: "Zaplacena", color: "success" },
|
||||
};
|
||||
|
||||
/** RECEIVED INVOICE (received_invoices). */
|
||||
export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
|
||||
unpaid: { label: "Neuhrazena", color: "warning" },
|
||||
paid: { label: "Uhrazena", color: "success" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Display label for a document's official number. Drafts (deferred numbering)
|
||||
* have a NULL/empty number until they are finalized — show "Koncept" instead
|
||||
* of an empty/`null` heading.
|
||||
*/
|
||||
export function documentNumberLabel(num: string | null | undefined): string {
|
||||
return num && String(num).trim() ? String(num) : "Koncept";
|
||||
}
|
||||
|
||||
/** Label with a safe fallback for an unknown/empty key. */
|
||||
export const statusLabel = (
|
||||
m: Record<string, StatusMeta>,
|
||||
k: string | null | undefined,
|
||||
): string => (k && m[k]?.label) || k || "—";
|
||||
|
||||
/** Chip color with a safe fallback for an unknown/empty key. */
|
||||
export const statusColor = (
|
||||
m: Record<string, StatusMeta>,
|
||||
k: string | null | undefined,
|
||||
): StatusColor => (k && m[k]?.color) || "default";
|
||||
|
||||
/**
|
||||
* Build `{ value, label }` filter options from a status map, optionally
|
||||
* prefixed with an "all" entry. Used by list-page status filters.
|
||||
*/
|
||||
export const statusOptions = (
|
||||
m: Record<string, StatusMeta>,
|
||||
allOption?: { value: string; label: string },
|
||||
): { value: string; label: string }[] => {
|
||||
const opts = Object.entries(m).map(([value, { label }]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
return allOption ? [allOption, ...opts] : opts;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
invoice: "Faktura",
|
||||
quotation: "Nabídka",
|
||||
order: "Objednávka",
|
||||
issued_order: "Objednávka vydaná",
|
||||
project: "Projekt",
|
||||
project_file: "Soubor projektu",
|
||||
customer: "Zákazník",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
interface LocationRaw {
|
||||
@@ -167,14 +166,15 @@ export const attendanceLocationOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "location", id],
|
||||
queryFn: async (): Promise<LocationRecord> => {
|
||||
const response = await apiFetch(
|
||||
// Route through jsonQuery so the response.ok / 401 / non-JSON cases are
|
||||
// normalized into the same errors as every other options helper, then
|
||||
// apply the location-specific shape transform on the unwrapped data.
|
||||
const data = await jsonQuery<LocationRaw | { record: LocationRaw }>(
|
||||
`/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 raw = (
|
||||
"record" in data && data.record ? data.record : data
|
||||
) as LocationRaw;
|
||||
const userName = raw.users
|
||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||
: raw.user_name || "";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export const dashboardOptions = () =>
|
||||
@@ -28,11 +27,12 @@ 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");
|
||||
// Route through jsonQuery for the shared response.ok / 401 / non-JSON
|
||||
// guards, then normalize the two possible payload shapes (a bare array
|
||||
// or `{ sessions: [...] }`).
|
||||
const data = await jsonQuery<Session[] | { sessions?: Session[] }>(
|
||||
"/api/admin/sessions",
|
||||
);
|
||||
return Array.isArray(data) ? data : (data.sessions ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -192,6 +192,31 @@ export const invoiceStatsOptions = (month: number, year: number) =>
|
||||
),
|
||||
});
|
||||
|
||||
// Per-currency total over the WHOLE filtered set of the ISSUED-invoice list.
|
||||
// Distinct from `invoiceStatsOptions` (the KPI cards) — hits /list-totals and
|
||||
// passes the same filters the issued list uses (search/status/month/year).
|
||||
export const invoiceTotalsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "list-totals", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
||||
`/api/admin/invoices/list-totals${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return data.totals ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "received", "stats", month, year],
|
||||
@@ -201,6 +226,30 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
||||
),
|
||||
});
|
||||
|
||||
// Per-currency total over the WHOLE filtered set of the received-invoice list.
|
||||
// Distinct from `receivedInvoiceStatsOptions` (the KPI cards) — hits
|
||||
// /list-totals and passes the same filters the received list uses
|
||||
// (month/year/search). `amount` is GROSS (VAT-inclusive).
|
||||
export const receivedInvoiceTotalsOptions = (filters: {
|
||||
month?: number;
|
||||
year?: number;
|
||||
search?: string;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "received", "list-totals", filters],
|
||||
queryFn: async () => {
|
||||
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);
|
||||
const qs = params.toString();
|
||||
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
||||
`/api/admin/received-invoices/list-totals${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return data.totals ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const invoiceDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", id],
|
||||
|
||||
106
src/admin/lib/queries/issued-orders.ts
Normal file
106
src/admin/lib/queries/issued-orders.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface IssuedOrder {
|
||||
id: number;
|
||||
po_number: string | null;
|
||||
customer_id: number | null;
|
||||
customer_name: string | null;
|
||||
status: string;
|
||||
currency: string | null;
|
||||
order_date: string | null;
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IssuedOrderItem {
|
||||
id?: number;
|
||||
description: string | null;
|
||||
item_description: string | null;
|
||||
quantity: number | string | null;
|
||||
unit: string | null;
|
||||
unit_price: number | string | null;
|
||||
vat_rate: number | string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface IssuedOrderDetail extends IssuedOrder {
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: number | string | null;
|
||||
exchange_rate: number | string | null;
|
||||
delivery_date: string | null;
|
||||
language: string | null;
|
||||
delivery_terms: string | null;
|
||||
payment_terms: string | null;
|
||||
issued_by: string | null;
|
||||
notes: string | null;
|
||||
internal_notes: string | null;
|
||||
items: IssuedOrderItem[];
|
||||
customer: Record<string, unknown> | null;
|
||||
valid_transitions: string[];
|
||||
}
|
||||
|
||||
export const issuedOrderListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-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));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery<IssuedOrder>(
|
||||
`/api/admin/issued-orders${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const issuedOrderStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", "stats", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
||||
`/api/admin/issued-orders/stats${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return data.totals ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const issuedOrderDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", id],
|
||||
queryFn: () =>
|
||||
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ItemTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -91,6 +96,27 @@ export const offerListOptions = (filters: {
|
||||
},
|
||||
});
|
||||
|
||||
export const offerStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["offers", "stats", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
||||
`/api/admin/offers/stats${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return data.totals ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export interface OfferItemData {
|
||||
id?: number;
|
||||
description: string;
|
||||
|
||||
@@ -61,6 +61,9 @@ export const orderListOptions = (filters: {
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", "list", filters],
|
||||
@@ -71,11 +74,41 @@ export const orderListOptions = (filters: {
|
||||
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.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const orderStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", "stats", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
||||
`/api/admin/orders/stats${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return data.totals ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const orderDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", id],
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface CompanySettingsData {
|
||||
offer_number_pattern?: string;
|
||||
order_number_pattern?: string;
|
||||
invoice_number_pattern?: string;
|
||||
issued_order_number_pattern?: string;
|
||||
issued_order_type_code?: string;
|
||||
project_number_pattern?: string;
|
||||
project_type_code?: string;
|
||||
warehouse_receipt_prefix?: string;
|
||||
warehouse_receipt_number_pattern?: string;
|
||||
warehouse_issue_prefix?: string;
|
||||
|
||||
@@ -300,7 +300,7 @@ export default function Attendance() {
|
||||
>({
|
||||
url: () => `${API_BASE}/leave-requests`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance", "leave-requests", "users"],
|
||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
||||
});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -323,6 +323,10 @@ export default function Attendance() {
|
||||
const mountedRef = useRef(true);
|
||||
const latestActionRef = useRef<string | null>(null);
|
||||
|
||||
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
|
||||
// actually advances instead of freezing at first paint.
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -332,6 +336,11 @@ export default function Attendance() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Sync notes from query data when the shift changes
|
||||
useEffect(() => {
|
||||
if (statusQuery.data) {
|
||||
@@ -475,10 +484,20 @@ export default function Attendance() {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
|
||||
// would parse as UTC midnight; reading it back with local getDay()/getDate()
|
||||
// can land on the wrong calendar day in the evening Prague window.
|
||||
const parseLocalDate = (s: string): Date | null => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
};
|
||||
|
||||
const calculateBusinessDays = (from: string, to: string) => {
|
||||
if (!from || !to) return 0;
|
||||
const start = new Date(from);
|
||||
const end = new Date(to);
|
||||
const start = parseLocalDate(from);
|
||||
const end = parseLocalDate(to);
|
||||
if (!start || !end) return 0;
|
||||
if (end < start) return 0;
|
||||
let days = 0;
|
||||
const current = new Date(start);
|
||||
@@ -673,7 +692,7 @@ export default function Attendance() {
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{new Date().toLocaleTimeString("cs-CZ", {
|
||||
{now.toLocaleTimeString("cs-CZ", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
|
||||
@@ -168,8 +168,6 @@ export default function AttendanceBalances() {
|
||||
userName: string;
|
||||
}>({ show: false, userId: null, userName: "" });
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const editMutation = useApiMutation<
|
||||
{
|
||||
user_id: string;
|
||||
@@ -199,6 +197,8 @@ export default function AttendanceBalances() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||||
setEditingUser({ id: userId, name: balance.name });
|
||||
setEditForm({
|
||||
@@ -767,7 +767,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_total: parseFloat(e.target.value),
|
||||
vacation_total: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
|
||||
@@ -781,7 +781,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_used: parseFloat(e.target.value),
|
||||
vacation_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
@@ -795,7 +795,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
sick_used: parseFloat(e.target.value),
|
||||
sick_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function AttendanceCreate() {
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
leave_hours: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
|
||||
@@ -184,6 +184,13 @@ export default function AttendanceLocation() {
|
||||
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
// Show the spinner while the record is still loading — this must precede the
|
||||
// `!record` null-return below, otherwise a pending query (record === undefined)
|
||||
// renders a blank page and this branch is dead.
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@@ -196,10 +203,6 @@ export default function AttendanceLocation() {
|
||||
: record.shift_date;
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
|
||||
@@ -182,10 +182,6 @@ export default function AuditLog() {
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
const pagination = logsData?.pagination ?? null;
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const cleanupMutation = useApiMutation<
|
||||
{ days: number; confirm?: string },
|
||||
{ message?: string; error?: string }
|
||||
@@ -199,6 +195,10 @@ export default function AuditLog() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
|
||||
@@ -131,6 +131,10 @@ interface CompanyForm {
|
||||
offer_number_pattern: string;
|
||||
order_number_pattern: string;
|
||||
invoice_number_pattern: string;
|
||||
issued_order_number_pattern: string;
|
||||
issued_order_type_code: string;
|
||||
project_number_pattern: string;
|
||||
project_type_code: string;
|
||||
quotation_prefix: string;
|
||||
order_type_code: string;
|
||||
invoice_type_code: string;
|
||||
@@ -182,6 +186,10 @@ export default function CompanySettings({
|
||||
offer_number_pattern: "",
|
||||
order_number_pattern: "",
|
||||
invoice_number_pattern: "",
|
||||
issued_order_number_pattern: "",
|
||||
issued_order_type_code: "",
|
||||
project_number_pattern: "",
|
||||
project_type_code: "",
|
||||
quotation_prefix: "",
|
||||
order_type_code: "",
|
||||
invoice_type_code: "",
|
||||
@@ -233,7 +241,7 @@ export default function CompanySettings({
|
||||
});
|
||||
|
||||
const bankMutation = useApiMutation<
|
||||
{ id?: number; bank: BankForm },
|
||||
BankForm & { id?: number },
|
||||
{ message?: string }
|
||||
>({
|
||||
url: (input) =>
|
||||
@@ -347,6 +355,11 @@ export default function CompanySettings({
|
||||
offer_number_pattern: settingsData.offer_number_pattern || "",
|
||||
order_number_pattern: settingsData.order_number_pattern || "",
|
||||
invoice_number_pattern: settingsData.invoice_number_pattern || "",
|
||||
issued_order_number_pattern:
|
||||
settingsData.issued_order_number_pattern || "",
|
||||
issued_order_type_code: settingsData.issued_order_type_code || "",
|
||||
project_number_pattern: settingsData.project_number_pattern || "",
|
||||
project_type_code: settingsData.project_type_code || "",
|
||||
quotation_prefix: settingsData.quotation_prefix || "",
|
||||
order_type_code: settingsData.order_type_code || "",
|
||||
invoice_type_code: settingsData.invoice_type_code || "",
|
||||
@@ -419,8 +432,8 @@ export default function CompanySettings({
|
||||
setBankSaving(true);
|
||||
try {
|
||||
const result = await bankMutation.mutateAsync({
|
||||
...bankForm,
|
||||
id: editingBank ?? undefined,
|
||||
bank: bankForm,
|
||||
});
|
||||
alert.success(result?.message || "Uloženo");
|
||||
resetBankForm();
|
||||
@@ -1147,12 +1160,33 @@ export default function CompanySettings({
|
||||
codeKey: null,
|
||||
},
|
||||
{
|
||||
label: "Objednávky a projekty",
|
||||
label: "Objednávky přijaté",
|
||||
patternKey: "order_number_pattern" as const,
|
||||
defaultPattern: "{YY}{CODE}{NNNN}",
|
||||
prefixKey: null,
|
||||
codeKey: "order_type_code" as const,
|
||||
codeLabel: "Typový kód",
|
||||
// Mirror the server generator's code fallback so the preview
|
||||
// matches the real generated number when the field is empty.
|
||||
defaultCode: "71",
|
||||
},
|
||||
{
|
||||
label: "Objednávky vydané",
|
||||
patternKey: "issued_order_number_pattern" as const,
|
||||
defaultPattern: "{YY}{CODE}{NNNN}",
|
||||
prefixKey: null,
|
||||
codeKey: "issued_order_type_code" as const,
|
||||
codeLabel: "Typový kód",
|
||||
defaultCode: "72",
|
||||
},
|
||||
{
|
||||
label: "Projekty",
|
||||
patternKey: "project_number_pattern" as const,
|
||||
defaultPattern: "{YY}{CODE}{NNNN}",
|
||||
prefixKey: null,
|
||||
codeKey: "project_type_code" as const,
|
||||
codeLabel: "Typový kód",
|
||||
defaultCode: "73",
|
||||
},
|
||||
{
|
||||
label: "Faktury",
|
||||
@@ -1161,6 +1195,7 @@ export default function CompanySettings({
|
||||
prefixKey: null,
|
||||
codeKey: "invoice_type_code" as const,
|
||||
codeLabel: "Typový kód",
|
||||
defaultCode: "81",
|
||||
},
|
||||
{
|
||||
label: "Sklad — Příjmy",
|
||||
@@ -1191,7 +1226,11 @@ export default function CompanySettings({
|
||||
const yyyy = String(new Date().getFullYear());
|
||||
const yy = yyyy.slice(-2);
|
||||
const prefix = cfg.prefixKey ? form[cfg.prefixKey] || "NA" : "";
|
||||
const code = cfg.codeKey ? form[cfg.codeKey] || "" : "";
|
||||
const code = cfg.codeKey
|
||||
? form[cfg.codeKey] ||
|
||||
(cfg as { defaultCode?: string }).defaultCode ||
|
||||
""
|
||||
: "";
|
||||
const preview = pattern.replace(
|
||||
/\{(\w+)\}/g,
|
||||
(m: string, k: string) => {
|
||||
@@ -1309,7 +1348,7 @@ export default function CompanySettings({
|
||||
onChange={(e) =>
|
||||
updateField("default_vat_rate", Number(e.target.value))
|
||||
}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Dostupné měny">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -47,10 +48,11 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import apiFetch from "../utils/api";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { invoiceDetailOptions } from "../lib/queries/invoices";
|
||||
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { bankAccountsOptions } from "../lib/queries/common";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -59,33 +61,57 @@ import {
|
||||
DateField,
|
||||
Field,
|
||||
StatusChip,
|
||||
CheckboxField,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
RichTextView,
|
||||
CustomerPicker,
|
||||
headerActionsSx,
|
||||
} from "../ui";
|
||||
import {
|
||||
INVOICE_STATUS,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
documentNumberLabel,
|
||||
} from "../lib/documentStatus";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
issued: "Vystavena",
|
||||
paid: "Zaplacena",
|
||||
overdue: "Po splatnosti",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
issued: "info",
|
||||
paid: "success",
|
||||
overdue: "error",
|
||||
};
|
||||
/**
|
||||
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
|
||||
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
|
||||
* used. We build the Date from the local year/month/day parts (so it's a local
|
||||
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
|
||||
* it back with local getters — never via `toISOString()`, which would shift a
|
||||
* day during the evening Prague window. See utils/formatters.todayLocalStr.
|
||||
*/
|
||||
function addDaysLocalStr(days: number, base?: string): string {
|
||||
let y: number, m: number, d: number;
|
||||
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
|
||||
if (parts) {
|
||||
y = Number(parts[1]);
|
||||
m = Number(parts[2]) - 1;
|
||||
d = Number(parts[3]);
|
||||
} else {
|
||||
const now = new Date();
|
||||
y = now.getFullYear();
|
||||
m = now.getMonth();
|
||||
d = now.getDate();
|
||||
}
|
||||
const result = new Date(y, m, d + days);
|
||||
const mm = String(result.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(result.getDate()).padStart(2, "0");
|
||||
return `${result.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const TRANSITION_LABELS: Record<string, string> = { paid: "Zaplaceno" };
|
||||
|
||||
// The live (finalized) status for an invoice — assigning it (on create or on
|
||||
// finalizing a draft) makes the backend consume the official invoice number.
|
||||
const LIVE_STATUS = "issued";
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -143,9 +169,13 @@ interface InvoiceItem {
|
||||
_key: string;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: number;
|
||||
// Held as the raw typed string while editing so the field can be cleared
|
||||
// (empty renders fine in a type="number" input). Coerced via Number(x) || 0
|
||||
// only where used (live totals + save payload). vat_rate stays numeric: it
|
||||
// is edited via a Select, never a free-text number input.
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
unit_price: string | number;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
@@ -274,6 +304,9 @@ function SortableInvoiceRow({
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
multiline
|
||||
rows={2}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -285,10 +318,8 @@ function SortableInvoiceRow({
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
onUpdate(index, "quantity", Number(e.target.value))
|
||||
}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
/>
|
||||
<TextField
|
||||
@@ -300,10 +331,8 @@ function SortableInvoiceRow({
|
||||
<TextField
|
||||
label="Jedn. cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
onUpdate(index, "unit_price", Number(e.target.value))
|
||||
}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "any" } }}
|
||||
/>
|
||||
{apply_vat && (
|
||||
@@ -376,15 +405,23 @@ function SortableInvoiceRow({
|
||||
onUpdate(index, "item_description", e.target.value)
|
||||
}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }}
|
||||
multiline
|
||||
rows={2}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
opacity: 0.8,
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
@@ -400,10 +437,8 @@ function SortableInvoiceRow({
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
onUpdate(index, "unit_price", Number(e.target.value))
|
||||
}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "any" } }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
@@ -486,12 +521,12 @@ export default function InvoiceDetail() {
|
||||
const fromOrderId =
|
||||
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
|
||||
|
||||
const [form, setForm] = useState<InvoiceForm>({
|
||||
const [form, setForm] = useState<InvoiceForm>(() => ({
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
order_id: fromOrderId ? Number(fromOrderId) : null,
|
||||
issue_date: todayLocalStr(),
|
||||
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
|
||||
due_date: addDaysLocalStr(14),
|
||||
tax_date: todayLocalStr(),
|
||||
currency: "CZK",
|
||||
apply_vat: 1,
|
||||
@@ -507,10 +542,10 @@ export default function InvoiceDetail() {
|
||||
bank_swift: "",
|
||||
bank_iban: "",
|
||||
bank_account: "",
|
||||
});
|
||||
}));
|
||||
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
const [items, setItems] = useState<InvoiceItem[]>([
|
||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_key: "inv-1",
|
||||
description: "",
|
||||
@@ -523,13 +558,14 @@ export default function InvoiceDetail() {
|
||||
]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Which save action is in flight ("draft" | LIVE_STATUS | "save"), so only the
|
||||
// clicked button shows its spinner — `saving` still disables all of them to
|
||||
// prevent a concurrent double-submit.
|
||||
const [savingAction, setSavingAction] = useState<string | null>(null);
|
||||
const [dataReady, setDataReady] = useState(false);
|
||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||
const initialSnapshotRef = useRef<string | null>(null);
|
||||
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
|
||||
const companySettings = useQuery(companySettingsOptions()).data;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -555,16 +591,6 @@ export default function InvoiceDetail() {
|
||||
label: `${v}%`,
|
||||
}));
|
||||
|
||||
const DRAFT_KEY = "boha_invoice_draft";
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── TanStack Query ───
|
||||
|
||||
const customersQuery = useQuery(offerCustomersOptions());
|
||||
@@ -612,6 +638,20 @@ export default function InvoiceDetail() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// After an in-place draft→issued finalize, the one-shot hydration effect below
|
||||
// (gated by dataReady) won't re-run, so the freshly-assigned number from the
|
||||
// refetched query is mirrored into the local field here (matches the
|
||||
// issued-order page's po_number resync).
|
||||
useEffect(() => {
|
||||
if (
|
||||
isEdit &&
|
||||
invoice?.invoice_number &&
|
||||
invoice.invoice_number !== invoiceNumber
|
||||
) {
|
||||
setInvoiceNumber(invoice.invoice_number);
|
||||
}
|
||||
}, [isEdit, invoice?.invoice_number, invoiceNumber]);
|
||||
|
||||
// ─── Sync query data to form state ───
|
||||
|
||||
// Edit mode: populate form from invoice data
|
||||
@@ -641,15 +681,9 @@ export default function InvoiceDetail() {
|
||||
customer_id: inv.customer_id || null,
|
||||
customer_name: inv.customer_name || "",
|
||||
order_id: inv.order_id || null,
|
||||
issue_date: inv.issue_date
|
||||
? new Date(inv.issue_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
due_date: inv.due_date
|
||||
? new Date(inv.due_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
tax_date: inv.tax_date
|
||||
? new Date(inv.tax_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
issue_date: normalizeDateStr(inv.issue_date),
|
||||
due_date: normalizeDateStr(inv.due_date),
|
||||
tax_date: normalizeDateStr(inv.tax_date),
|
||||
currency: inv.currency || "CZK",
|
||||
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
||||
vat_rate: Number(inv.vat_rate) || 21,
|
||||
@@ -713,7 +747,7 @@ export default function InvoiceDetail() {
|
||||
bankAccountsQuery.isLoading,
|
||||
bankAccountsQuery.data,
|
||||
customersQuery.isLoading,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Create mode: populate form from query data
|
||||
useEffect(() => {
|
||||
@@ -726,7 +760,7 @@ export default function InvoiceDetail() {
|
||||
return;
|
||||
if (fromOrderId && orderDataQuery.isLoading) return;
|
||||
|
||||
// Set invoice number
|
||||
// Set invoice number from the server's next-number.
|
||||
if (nextNumberQuery.data) {
|
||||
setInvoiceNumber(nextNumberQuery.data);
|
||||
}
|
||||
@@ -791,7 +825,7 @@ export default function InvoiceDetail() {
|
||||
orderDataQuery.data,
|
||||
companySettings,
|
||||
bankAccounts,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Capture initial snapshot for dirty-checking once data sync completes.
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
@@ -817,31 +851,9 @@ export default function InvoiceDetail() {
|
||||
|
||||
const computedDueDate = useMemo(() => {
|
||||
if (!form.issue_date) return "";
|
||||
const d = new Date(form.issue_date);
|
||||
d.setDate(d.getDate() + dueDays);
|
||||
return d.toISOString().split("T")[0];
|
||||
return addDaysLocalStr(dueDays, form.issue_date);
|
||||
}, [form.issue_date, dueDays]);
|
||||
|
||||
// ─── Create mode: customer filtering ───
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!customerSearch) return customers;
|
||||
const q = customerSearch.toLowerCase();
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
(c.name || "").toLowerCase().includes(q) ||
|
||||
(c.company_id || "").includes(customerSearch) ||
|
||||
(c.city || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [customers, customerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowCustomerDropdown(false);
|
||||
if (showCustomerDropdown) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
}, [showCustomerDropdown]);
|
||||
|
||||
const selectBankAccount = (accountId: string) => {
|
||||
const acc = bankAccounts.find((a) => a.id === Number(accountId));
|
||||
if (acc) {
|
||||
@@ -865,15 +877,14 @@ export default function InvoiceDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectCustomer = (customer: Customer) => {
|
||||
const selectCustomer = (id: number | null) => {
|
||||
const customer = id != null ? customers.find((c) => c.id === id) : null;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: customer.id,
|
||||
customer_name: customer.name,
|
||||
customer_id: id,
|
||||
customer_name: customer?.name ?? "",
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, customer_id: "" }));
|
||||
setCustomerSearch("");
|
||||
setShowCustomerDropdown(false);
|
||||
};
|
||||
|
||||
// ─── Create mode: items management ───
|
||||
@@ -955,9 +966,7 @@ export default function InvoiceDetail() {
|
||||
invalidate: ["invoices", "orders"],
|
||||
});
|
||||
|
||||
const handleCreateSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
const handleCreateSubmit = async (targetStatus?: string) => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
|
||||
if (!form.issue_date) newErrors.issue_date = "Zadejte datum";
|
||||
@@ -971,6 +980,7 @@ export default function InvoiceDetail() {
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
setSavingAction(targetStatus ?? "save");
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
...form,
|
||||
@@ -979,13 +989,23 @@ export default function InvoiceDetail() {
|
||||
.filter((i) => i.description.trim())
|
||||
.map((item, i) => ({
|
||||
...item,
|
||||
// Coerce the raw typed strings back to numbers so an empty/partial
|
||||
// field never reaches the server as NaN (mirrors createTotals).
|
||||
quantity: Number(item.quantity) || 0,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
position: i,
|
||||
})),
|
||||
};
|
||||
if (isEdit) payload.invoice_number = invoiceNumber;
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live invoice sends no status — the backend keeps
|
||||
// its current status.
|
||||
if (targetStatus) payload.status = targetStatus;
|
||||
// The invoice number is NOT user-editable for drafts (it is NULL until
|
||||
// finalized). Send it back only when editing an already-numbered invoice.
|
||||
if (isEdit && invoice?.status !== "draft")
|
||||
payload.invoice_number = invoiceNumber;
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
if (!isEdit) clearDraft();
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
if (!isEdit) {
|
||||
@@ -1001,9 +1021,20 @@ export default function InvoiceDetail() {
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Native form submit (e.g. Enter): route to the right save path. Create →
|
||||
// finalize live; editing a draft → save draft (the visible primary button);
|
||||
// editing a live invoice → plain save (no status change).
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isEdit) void handleCreateSubmit(LIVE_STATUS);
|
||||
else if (invoice?.status === "draft") void handleCreateSubmit("draft");
|
||||
else void handleCreateSubmit();
|
||||
};
|
||||
|
||||
// ─── Edit mode: status change ───
|
||||
const handleStatusChange = async () => {
|
||||
if (!statusConfirm.status) return;
|
||||
@@ -1091,7 +1122,7 @@ export default function InvoiceDetail() {
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices"
|
||||
to="/invoices?tab=issued"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
@@ -1107,16 +1138,16 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {invoice.invoice_number}
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[invoice.status] || invoice.status}
|
||||
color={STATUS_COLORS[invoice.status] || "default"}
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
color={statusColor(INVOICE_STATUS, invoice.status)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
{hasPermission("invoices.export") && (
|
||||
{hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
@@ -1133,7 +1164,11 @@ export default function InvoiceDetail() {
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission("invoices.delete") && (
|
||||
{/* A paid invoice is a settled financial record — never deletable
|
||||
from the UI. (Backend rejection of deleting a paid invoice is a
|
||||
separate hardening, out of scope here.) */}
|
||||
{hasPermission("invoices.delete") &&
|
||||
invoice.status !== "paid" && (
|
||||
<Button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
variant="outlined"
|
||||
@@ -1395,11 +1430,11 @@ export default function InvoiceDetail() {
|
||||
<TableCell sx={{ width: "5rem" }} align="center">
|
||||
Jednotka
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
%DPH
|
||||
DPH
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "9rem" }} align="right">
|
||||
Celkem
|
||||
@@ -1606,7 +1641,7 @@ export default function InvoiceDetail() {
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices"
|
||||
to="/invoices?tab=issued"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
@@ -1624,11 +1659,11 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {invoice.invoice_number}
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[invoice.status] || invoice.status}
|
||||
color={STATUS_COLORS[invoice.status] || "default"}
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
color={statusColor(INVOICE_STATUS, invoice.status)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -1655,7 +1690,7 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
{isEdit && invoice && hasPermission("invoices.export") && (
|
||||
{isEdit && invoice && hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
@@ -1672,25 +1707,120 @@ export default function InvoiceDetail() {
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreateSubmit} disabled={saving}>
|
||||
{saving ? (
|
||||
{/* ── Create mode: two-button save ── */}
|
||||
{!isEdit && (
|
||||
<>
|
||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => handleCreateSubmit("draft")}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === "draft" ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
"Uložit koncept"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateSubmit(LIVE_STATUS)}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === LIVE_STATUS ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Edit mode: draft → save-draft + finalize ── */}
|
||||
{isEdit && invoice && invoice.status === "draft" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => handleCreateSubmit("draft")}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === "draft" ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
"Uložit koncept"
|
||||
)}
|
||||
</Button>
|
||||
{hasPermission("invoices.edit") && (
|
||||
<Button
|
||||
onClick={() => handleCreateSubmit(LIVE_STATUS)}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === LIVE_STATUS ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Vystavit"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* A draft is deleted rather than cancelled. */}
|
||||
{hasPermission("invoices.delete") && (
|
||||
<Button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Edit mode: live (non-draft) doc — unchanged ── */}
|
||||
{isEdit && invoice && invoice.status !== "draft" && (
|
||||
<>
|
||||
<Button onClick={() => handleCreateSubmit()} disabled={saving}>
|
||||
{savingAction === "save" ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit"
|
||||
)}
|
||||
</Button>
|
||||
{isEdit && invoice && (
|
||||
<>
|
||||
{hasPermission("invoices.edit") &&
|
||||
invoice.valid_transitions?.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
onClick={() => setStatusConfirm({ show: true, status })}
|
||||
variant={status === "paid" ? "contained" : "outlined"}
|
||||
color={status === "paid" ? "primary" : "inherit"}
|
||||
variant={
|
||||
status === "cancelled" || status === "stornovana"
|
||||
? "outlined"
|
||||
: "contained"
|
||||
}
|
||||
color={
|
||||
status === "cancelled" || status === "stornovana"
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
disabled={statusChanging === status}
|
||||
>
|
||||
{statusChanging === status ? (
|
||||
@@ -1700,7 +1830,12 @@ export default function InvoiceDetail() {
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{hasPermission("invoices.delete") && (
|
||||
{/* A paid invoice is a settled financial record — never
|
||||
deletable from the UI. A paid invoice never actually reaches
|
||||
this branch (it renders the read-only view above), but the
|
||||
guard documents the invariant explicitly. */}
|
||||
{hasPermission("invoices.delete") &&
|
||||
invoice.status !== "paid" && (
|
||||
<Button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
variant="outlined"
|
||||
@@ -1715,7 +1850,7 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleCreateSubmit}>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
{/* Basic info */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
@@ -1736,115 +1871,13 @@ export default function InvoiceDetail() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
{form.customer_id ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ flex: 1 }}>
|
||||
{form.customer_name}
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
}))
|
||||
}
|
||||
title="Odebrat zákazníka"
|
||||
aria-label="Odebrat zákazníka"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{ position: "relative" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TextField
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
placeholder="Hledat zákazníka (název, IČ, město)..."
|
||||
autoComplete="off"
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
{showCustomerDropdown && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
mt: 0.5,
|
||||
maxHeight: 280,
|
||||
overflowY: "auto",
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
boxShadow: 3,
|
||||
}}
|
||||
>
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<Box sx={{ px: 1.5, py: 1, color: "text.secondary" }}>
|
||||
Žádní zákazníci
|
||||
</Box>
|
||||
) : (
|
||||
filteredCustomers.slice(0, 10).map((c) => (
|
||||
<Box
|
||||
key={c.id}
|
||||
onMouseDown={() => selectCustomer(c)}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
cursor: "pointer",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
>
|
||||
<Box>{c.name}</Box>
|
||||
{(c.company_id || c.city) && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: "0.8rem",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{c.company_id && `IČ: ${c.company_id}`}
|
||||
{c.city && ` · ${c.city}`}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<TextField
|
||||
@@ -1917,7 +1950,7 @@ export default function InvoiceDetail() {
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
|
||||
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
@@ -1962,29 +1995,35 @@ export default function InvoiceDetail() {
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{/* Document-default VAT rate. Presentational only — it writes the
|
||||
already-existing `form.vat_rate` (used to seed new line rows /
|
||||
as the per-line fallback). Totals are computed per-line from
|
||||
`item.vat_rate`, so changing this does NOT alter computed VAT. */}
|
||||
<Field label="Sazba DPH">
|
||||
<Select
|
||||
value={String(form.vat_rate)}
|
||||
disabled={!form.apply_vat}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
|
||||
}
|
||||
options={vatOptions.map((o) => ({
|
||||
value: String(o.value),
|
||||
label: o.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DPH">
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
|
||||
<CheckboxField
|
||||
label="Uplatnit DPH"
|
||||
checked={!!form.apply_vat}
|
||||
onChange={(e) =>
|
||||
onChange={(v) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apply_vat: e.target.checked ? 1 : 0,
|
||||
apply_vat: v ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Box component="span">Uplatnit DPH</Box>
|
||||
</Box>
|
||||
</Field>
|
||||
</Box>
|
||||
@@ -2087,13 +2126,13 @@ export default function InvoiceDetail() {
|
||||
#
|
||||
</TableCell>
|
||||
<TableCell>Popis</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Množství
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
Jednotka
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{form.apply_vat ? (
|
||||
@@ -2198,13 +2237,9 @@ export default function InvoiceDetail() {
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={4}
|
||||
<RichEditor
|
||||
value={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
||||
placeholder="Poznámky zobrazené na faktuře..."
|
||||
/>
|
||||
</Card>
|
||||
@@ -2218,7 +2253,7 @@ export default function InvoiceDetail() {
|
||||
onClose={() => setStatusConfirm({ show: false, status: null })}
|
||||
onConfirm={handleStatusChange}
|
||||
title="Změnit stav faktury"
|
||||
message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${STATUS_LABELS[statusConfirm.status || ""]}"?`}
|
||||
message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${statusLabel(INVOICE_STATUS, statusConfirm.status)}"?`}
|
||||
confirmText={
|
||||
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
||||
}
|
||||
|
||||
@@ -10,12 +10,20 @@ import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatMultiCurrency,
|
||||
formatDate,
|
||||
czechPlural,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import {
|
||||
invoiceListOptions,
|
||||
invoiceStatsOptions,
|
||||
invoiceTotalsOptions,
|
||||
type Invoice,
|
||||
type CurrencyAmount,
|
||||
} from "../lib/queries/invoices";
|
||||
@@ -28,7 +36,6 @@ import {
|
||||
TextField,
|
||||
StatusChip,
|
||||
StatCard,
|
||||
Alert,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
FilterBar,
|
||||
@@ -39,10 +46,16 @@ import {
|
||||
type TabDef,
|
||||
type StatCardColor,
|
||||
} from "../ui";
|
||||
import {
|
||||
INVOICE_STATUS,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
statusOptions,
|
||||
documentNumberLabel,
|
||||
} from "../lib/documentStatus";
|
||||
|
||||
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
|
||||
const API_BASE = "/api/admin";
|
||||
const DRAFT_KEY = "boha_invoice_draft";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"leden",
|
||||
@@ -59,11 +72,6 @@ const MONTH_NAMES = [
|
||||
"prosinec",
|
||||
];
|
||||
|
||||
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
|
||||
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
|
||||
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
|
||||
}
|
||||
|
||||
function formatCzkWithDetail(
|
||||
amounts: CurrencyAmount[],
|
||||
totalCzk: number | null | undefined,
|
||||
@@ -80,33 +88,12 @@ function formatCzkWithDetail(
|
||||
return { value: formatMultiCurrency(amounts), detail: null };
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
issued: "Vystavena",
|
||||
paid: "Zaplacena",
|
||||
overdue: "Po splatnosti",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "success" | "warning" | "error"
|
||||
> = {
|
||||
issued: "warning",
|
||||
paid: "success",
|
||||
overdue: "error",
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: "", label: "Vše" },
|
||||
{ value: "issued", label: "Vystavené" },
|
||||
{ value: "paid", label: "Zaplacené" },
|
||||
{ value: "overdue", label: "Po splatnosti" },
|
||||
];
|
||||
|
||||
interface DraftData {
|
||||
form: Record<string, unknown>;
|
||||
items: Record<string, unknown>[];
|
||||
savedAt?: string;
|
||||
}
|
||||
// Filter tabs derive from INVOICE_STATUS so `draft` ("Koncept") flows in
|
||||
// automatically; the leading "Všechny" tab is the all-statuses filter.
|
||||
const STATUS_FILTERS = statusOptions(INVOICE_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny",
|
||||
});
|
||||
|
||||
const PlusIcon = (
|
||||
<svg
|
||||
@@ -233,6 +220,9 @@ export default function Invoices() {
|
||||
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
|
||||
const [statsYear, setStatsYear] = useState(now.getFullYear());
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
// Track first successful list load so later refetches (filter/status/month/
|
||||
// page change) keep the table visible instead of flashing the full skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -275,26 +265,6 @@ export default function Invoices() {
|
||||
}>({ show: false, invoice: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<DraftData | null>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as DraftData;
|
||||
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const discardDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
@@ -314,6 +284,24 @@ export default function Invoices() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-currency total over the WHOLE filtered set of the ISSUED list (not one
|
||||
// page). Uses the SAME filters as the issued list query so the summary
|
||||
// matches what's shown. Separate from the KPI `statsQuery` above.
|
||||
const listTotalsQuery = useQuery(
|
||||
invoiceTotalsOptions({
|
||||
search,
|
||||
status: statusFilter || undefined,
|
||||
month: statsMonth,
|
||||
year: statsYear,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mark first load done in an effect (not during render) to avoid a
|
||||
// render-phase ref mutation.
|
||||
useEffect(() => {
|
||||
if (!initialLoad) hasLoadedOnce.current = true;
|
||||
}, [initialLoad]);
|
||||
|
||||
if (!hasPermission("invoices.view")) return <Forbidden />;
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -393,7 +381,10 @@ export default function Invoices() {
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoad) {
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/status/month/page change) keep the table visible (the
|
||||
// Card dims via `loading`/isFetching) so it doesn't flash.
|
||||
if (initialLoad && !hasLoadedOnce.current) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
@@ -499,12 +490,14 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
// Per-row overdue tint (replaces offers-expired-row).
|
||||
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
|
||||
// to avoid the UTC-vs-local off-by-one near midnight Prague.
|
||||
const rowSx = (inv: Invoice) => {
|
||||
const isOverdue =
|
||||
inv.status === "overdue" ||
|
||||
(inv.status === "issued" &&
|
||||
inv.due_date &&
|
||||
new Date(inv.due_date) < new Date(new Date().toDateString()));
|
||||
!!inv.due_date &&
|
||||
normalizeDateStr(inv.due_date) < todayLocalStr());
|
||||
if (isOverdue) {
|
||||
return {
|
||||
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||||
@@ -534,7 +527,7 @@ export default function Invoices() {
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{inv.invoice_number}
|
||||
{documentNumberLabel(inv.invoice_number)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
@@ -552,13 +545,13 @@ export default function Invoices() {
|
||||
render: (inv) =>
|
||||
inv.status === "paid" ? (
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[inv.status]}
|
||||
color={STATUS_COLORS[inv.status] ?? "default"}
|
||||
label={statusLabel(INVOICE_STATUS, inv.status)}
|
||||
color={statusColor(INVOICE_STATUS, inv.status)}
|
||||
/>
|
||||
) : (
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[inv.status] || inv.status}
|
||||
color={STATUS_COLORS[inv.status] ?? "default"}
|
||||
label={statusLabel(INVOICE_STATUS, inv.status)}
|
||||
color={statusColor(INVOICE_STATUS, inv.status)}
|
||||
onClick={() => toggleStatus(inv)}
|
||||
/>
|
||||
),
|
||||
@@ -615,7 +608,7 @@ export default function Invoices() {
|
||||
>
|
||||
{inv.status === "paid" ? ViewIcon : EditIcon}
|
||||
</IconButton>
|
||||
{hasPermission("invoices.export") && (
|
||||
{hasPermission("invoices.view") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(inv)}
|
||||
@@ -626,7 +619,7 @@ export default function Invoices() {
|
||||
{pdfLoading === inv.id ? <CircularProgress size={16} /> : PdfIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("invoices.delete") && (
|
||||
{hasPermission("invoices.delete") && inv.status !== "paid" && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
@@ -751,77 +744,6 @@ export default function Invoices() {
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{draft && !search && !statusFilter && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert severity="info" onClose={discardDraft}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography component="span" sx={{ fontWeight: 600 }}>
|
||||
Koncept
|
||||
</Typography>
|
||||
{draft.savedAt && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ ml: 1, opacity: 0.8, fontSize: "0.875rem" }}
|
||||
>
|
||||
·{" "}
|
||||
{new Date(draft.savedAt).toLocaleTimeString("cs-CZ", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "text.secondary", mt: 0.25 }}
|
||||
>
|
||||
{(draft.form.customer_name as string) || "—"} ·{" "}
|
||||
{draft.form.issue_date
|
||||
? formatDate(draft.form.issue_date as string)
|
||||
: "—"}
|
||||
{" — "}
|
||||
{draft.form.due_date
|
||||
? formatDate(draft.form.due_date as string)
|
||||
: "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices/new"
|
||||
size="small"
|
||||
startIcon={EditIcon}
|
||||
>
|
||||
Pokračovat v konceptu
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={discardDraft}
|
||||
>
|
||||
Zahodit koncept
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Card sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<Invoice>
|
||||
columns={columns}
|
||||
@@ -832,6 +754,9 @@ export default function Invoices() {
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search || statusFilter ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají filtru." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné faktury."
|
||||
description={
|
||||
@@ -840,8 +765,30 @@ export default function Invoices() {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
|
||||
1465
src/admin/pages/IssuedOrderDetail.tsx
Normal file
1465
src/admin/pages/IssuedOrderDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
428
src/admin/pages/IssuedOrders.tsx
Normal file
428
src/admin/pages/IssuedOrders.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
} from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import {
|
||||
issuedOrderListOptions,
|
||||
issuedOrderStatsOptions,
|
||||
type IssuedOrder,
|
||||
} from "../lib/queries/issued-orders";
|
||||
import {
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
ConfirmDialog,
|
||||
TextField,
|
||||
Select,
|
||||
StatusChip,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
import {
|
||||
ISSUED_ORDER_STATUS,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
statusOptions,
|
||||
documentNumberLabel,
|
||||
} from "../lib/documentStatus";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny stavy",
|
||||
});
|
||||
|
||||
const ViewIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const PdfIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface IssuedOrdersProps {
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { sort, order, handleSort } = useTableSort("po_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
// Track first successful load so later refetches (filter/status/page change)
|
||||
// keep the table visible instead of flashing the full-page skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
order: IssuedOrder | null;
|
||||
}>({ show: false, order: null });
|
||||
|
||||
const deleteMutation = useApiMutation<
|
||||
{ id: number },
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: ({ id }) => `${API_BASE}/issued-orders/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["issued-orders"],
|
||||
onSuccess: (data) => {
|
||||
setDeleteConfirm({ show: false, order: null });
|
||||
alert.success(data?.message || "Objednávka byla smazána");
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
items: orders,
|
||||
pagination,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = usePaginatedQuery<IssuedOrder>(
|
||||
issuedOrderListOptions({
|
||||
search: debouncedSearch,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage: 20,
|
||||
status: status || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-currency total over the WHOLE filtered set (not one page). Uses the
|
||||
// SAME filters as the list query so the summary matches what's shown.
|
||||
const statsQuery = useQuery(
|
||||
issuedOrderStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: status || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mark first load done in an effect (not during render) to avoid a
|
||||
// render-phase ref mutation.
|
||||
useEffect(() => {
|
||||
if (!isPending) hasLoadedOnce.current = true;
|
||||
}, [isPending]);
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.order) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ id: deleteConfirm.order.id });
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async (o: IssuedOrder) => {
|
||||
// Authenticated open: apiFetch attaches the access token, then we hand the
|
||||
// resulting PDF blob to a window. A raw window.open(url) would be an
|
||||
// unauthenticated top-level navigation and 401 on the token-guarded route.
|
||||
const newWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba při generování PDF");
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/status/page change) keep the table visible (the Card
|
||||
// dims via isFetching) so it doesn't flash.
|
||||
if (isPending && !hasLoadedOnce.current) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const columns: DataColumn<IssuedOrder>[] = [
|
||||
{
|
||||
key: "po_number",
|
||||
header: "Číslo",
|
||||
width: "16%",
|
||||
sortKey: "po_number",
|
||||
mono: true,
|
||||
render: (o) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/orders/issued/${o.id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{documentNumberLabel(o.po_number)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Dodavatel",
|
||||
width: "24%",
|
||||
render: (o) => o.customer_name || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "14%",
|
||||
sortKey: "status",
|
||||
render: (o) => (
|
||||
<StatusChip
|
||||
label={statusLabel(ISSUED_ORDER_STATUS, o.status)}
|
||||
color={statusColor(ISSUED_ORDER_STATUS, o.status)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (o) => formatCurrency(o.total, o.currency ?? "CZK"),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
render: (o) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/orders/issued/${o.id}`)}
|
||||
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
|
||||
>
|
||||
{ViewIcon}
|
||||
</IconButton>
|
||||
{hasPermission("orders.view") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleExportPdf(o)}
|
||||
aria-label="PDF"
|
||||
title="PDF"
|
||||
>
|
||||
{PdfIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Per-status row tints — subtle channel-alpha washes (never a solid `.light`
|
||||
// fill, which is invisible-text in dark mode). Completed = success wash,
|
||||
// cancelled = faded/muted, matching the Offers/Invoices intensity.
|
||||
const rowSx = (o: IssuedOrder) => {
|
||||
if (o.status === "cancelled") {
|
||||
return {
|
||||
opacity: 0.6,
|
||||
"& td": { color: "var(--mui-palette-text-secondary)" },
|
||||
};
|
||||
}
|
||||
if (o.status === "completed") {
|
||||
return {
|
||||
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// Search/status filter is active → an empty list means "nothing matches"
|
||||
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
|
||||
const isFiltered = !!debouncedSearch || !!status;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle čísla nebo dodavatele..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 1 200px" }}>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<IssuedOrder>
|
||||
columns={columns}
|
||||
rows={orders}
|
||||
rowKey={(o) => o.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
isFiltered ? (
|
||||
<EmptyState title="Žádné objednávky neodpovídají filtru." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(statsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(statsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, order: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
deleteConfirm.order
|
||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -155,8 +155,6 @@ export default function LeaveApproval() {
|
||||
}>({ open: false, request: null });
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const approveMutation = useApiMutation<
|
||||
{ id: number; status: "approved" },
|
||||
unknown
|
||||
@@ -184,6 +182,8 @@ export default function LeaveApproval() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approveModal.request) return;
|
||||
try {
|
||||
|
||||
@@ -99,10 +99,20 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
if (!totpCode.trim()) return;
|
||||
|
||||
// Defensive: the 2FA form only renders after a loginToken is issued, but
|
||||
// guard against a null token (e.g. server returns requires2FA without one)
|
||||
// rather than passing `null!` through to verify2FA.
|
||||
if (!loginToken) {
|
||||
alert.error("Relace vypršela, přihlaste se prosím znovu");
|
||||
setShow2FA(false);
|
||||
setTotpCode("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await verify2FA(
|
||||
loginToken!,
|
||||
loginToken,
|
||||
totpCode.trim(),
|
||||
remember,
|
||||
useBackupCode,
|
||||
@@ -131,6 +141,7 @@ export default function Login() {
|
||||
setLoading,
|
||||
setShake,
|
||||
setTotpCode,
|
||||
setShow2FA,
|
||||
setAnimatingOut,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, todayLocalStr } from "../utils/formatters";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
@@ -74,20 +73,33 @@ import {
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
CustomerPicker,
|
||||
headerActionsSx,
|
||||
} from "../ui";
|
||||
import {
|
||||
OFFER_STATUS,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
documentNumberLabel,
|
||||
} from "../lib/documentStatus";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
const DRAFT_KEY = "boha_offer_draft";
|
||||
|
||||
// The live (finalized) status for an offer — assigning it (on create or on
|
||||
// finalizing a draft) makes the backend consume the official quotation number.
|
||||
const LIVE_STATUS = "active";
|
||||
|
||||
interface OfferItem {
|
||||
_key: string;
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: number;
|
||||
// Held as the raw typed string while editing so the field can be cleared
|
||||
// (an empty string renders fine in a type="number" input). Coerced via
|
||||
// Number(x) || 0 only where used (live totals + save payload).
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
unit_price: string | number;
|
||||
is_included_in_total: boolean;
|
||||
}
|
||||
|
||||
@@ -307,6 +319,9 @@ function SortableItemRow({
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -318,10 +333,11 @@ function SortableItemRow({
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
@@ -330,12 +346,13 @@ function SortableItemRow({
|
||||
InputProps={{ readOnly }}
|
||||
/>
|
||||
<TextField
|
||||
label="Cena/ks"
|
||||
label="Jedn. cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -417,17 +434,26 @@ function SortableItemRow({
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { fontSize: "0.8rem", opacity: 0.8 } }}
|
||||
multiline
|
||||
rows={2}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
opacity: 0.8,
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -440,10 +466,11 @@ function SortableItemRow({
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
@@ -477,20 +504,6 @@ function SortableItemRow({
|
||||
);
|
||||
}
|
||||
|
||||
function loadOfferDraft(): {
|
||||
form?: Record<string, unknown>;
|
||||
items?: unknown[];
|
||||
sections?: unknown[];
|
||||
} | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
console.error("Failed to load offer draft:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OfferDetail() {
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
@@ -525,10 +538,9 @@ export default function OfferDetail() {
|
||||
|
||||
// ---- TanStack Query hooks ----
|
||||
const offerQuery = useQuery(offerDetailOptions(id));
|
||||
const { data: customersData } = useQuery({
|
||||
...offerCustomersOptions(),
|
||||
enabled: !isEdit,
|
||||
});
|
||||
// Load customers in both modes: the shared CustomerPicker needs the full list
|
||||
// (even in edit/read-only mode) to resolve the current customer id → its row.
|
||||
const { data: customersData } = useQuery(offerCustomersOptions());
|
||||
const { data: templatesData } = useQuery(scopeTemplatesOptions());
|
||||
const { data: itemTemplates } = useQuery(itemTemplatesOptions());
|
||||
const { data: nextNumberData } = useQuery({
|
||||
@@ -542,54 +554,14 @@ export default function OfferDetail() {
|
||||
|
||||
const loading = isEdit && offerQuery.isLoading;
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Which save action is in flight ("draft" | LIVE_STATUS | "save"), so only the
|
||||
// clicked button shows its spinner — `saving` still disables all of them to
|
||||
// prevent a concurrent double-submit.
|
||||
const [savingAction, setSavingAction] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [form, setForm] = useState<OfferForm>(() => {
|
||||
// The draft is the unsaved-NEW-offer buffer — never restore it over an
|
||||
// existing offer in edit mode (it would also leak its stale item keys).
|
||||
const draft = isEdit ? null : loadOfferDraft();
|
||||
if (draft?.form) {
|
||||
return {
|
||||
quotation_number:
|
||||
(draft.form.quotation_number as string) || emptyForm.quotation_number,
|
||||
project_code:
|
||||
(draft.form.project_code as string) || emptyForm.project_code,
|
||||
customer_id:
|
||||
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
|
||||
customer_name:
|
||||
(draft.form.customer_name as string) || emptyForm.customer_name,
|
||||
created_at: (draft.form.created_at as string) || emptyForm.created_at,
|
||||
valid_until:
|
||||
(draft.form.valid_until as string) || emptyForm.valid_until,
|
||||
currency: (draft.form.currency as string) || emptyForm.currency,
|
||||
language: (draft.form.language as string) || emptyForm.language,
|
||||
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
||||
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
||||
};
|
||||
}
|
||||
return emptyForm;
|
||||
});
|
||||
const [items, setItems] = useState<OfferItem[]>(() => {
|
||||
const draft = isEdit ? null : loadOfferDraft();
|
||||
if (Array.isArray(draft?.items) && draft.items.length > 0) {
|
||||
// Re-key restored items: their persisted _key values are stale and the
|
||||
// counter restarts at 0 each load, so reusing them would collide with
|
||||
// newly-added rows (duplicate React key / dnd-kit id → the warning).
|
||||
return (draft.items as OfferItem[]).map((it) => ({
|
||||
...it,
|
||||
_key: `item-${++itemKeyCounter.current}`,
|
||||
}));
|
||||
}
|
||||
return [emptyItem()];
|
||||
});
|
||||
const [sections, setSections] = useState<ScopeSection[]>(() => {
|
||||
const draft = isEdit ? null : loadOfferDraft();
|
||||
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
|
||||
return draft.sections as ScopeSection[];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
const [form, setForm] = useState<OfferForm>(emptyForm);
|
||||
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
|
||||
const [sections, setSections] = useState<ScopeSection[]>([]);
|
||||
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
||||
const [offerStatus, setOfferStatus] = useState<string>("");
|
||||
|
||||
@@ -647,6 +619,11 @@ export default function OfferDetail() {
|
||||
const isLockedByOther = !!lockedBy;
|
||||
const readOnly = isInvalidated || isLockedByOther || isCompleted;
|
||||
const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo;
|
||||
// An offer is deletable only when it has NOT spawned an order (orderInfo —
|
||||
// deleting would orphan the linked order), is NOT invalidated, and is NOT
|
||||
// locked by another user. (Backend rejection of deleting an ordered offer is
|
||||
// a separate hardening, out of scope here.)
|
||||
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
|
||||
|
||||
// Sync offer detail data to form state on first load (edit mode)
|
||||
const formInitializedRef = useRef(false);
|
||||
@@ -758,7 +735,7 @@ export default function OfferDetail() {
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated]);
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
|
||||
|
||||
// Capture initial snapshot after loading completes (create mode)
|
||||
if (!loading && !initialSnapshotRef.current) {
|
||||
@@ -782,58 +759,19 @@ export default function OfferDetail() {
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowCustomerDropdown(false);
|
||||
if (showCustomerDropdown) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
}, [showCustomerDropdown]);
|
||||
|
||||
// Auto-save draft to localStorage (create mode only)
|
||||
const draftPayload = JSON.stringify({ form, items, sections });
|
||||
const debouncedDraft = useDebounce(draftPayload, 1500);
|
||||
useEffect(() => {
|
||||
if (isEdit) return;
|
||||
try {
|
||||
const data = JSON.parse(debouncedDraft);
|
||||
const draft = {
|
||||
form: {
|
||||
project_code: data.form.project_code ?? "",
|
||||
customer_id: data.form.customer_id ?? null,
|
||||
customer_name: data.form.customer_name ?? "",
|
||||
created_at: data.form.created_at ?? "",
|
||||
valid_until: data.form.valid_until ?? "",
|
||||
currency: data.form.currency ?? "CZK",
|
||||
language: data.form.language ?? "EN",
|
||||
vat_rate: data.form.vat_rate ?? 21,
|
||||
apply_vat: data.form.apply_vat ?? false,
|
||||
},
|
||||
items: data.items,
|
||||
sections: data.sections,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||
} catch (e) {
|
||||
console.error("Failed to save offer draft:", e);
|
||||
}
|
||||
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateForm = (field: keyof OfferForm, value: unknown) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
};
|
||||
|
||||
const selectCustomer = (c: Customer) => {
|
||||
setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name }));
|
||||
const selectCustomer = (id: number | null) => {
|
||||
const c = id != null ? customers.find((x) => x.id === id) : null;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: id,
|
||||
customer_name: c?.name ?? "",
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, customer_id: undefined }));
|
||||
setCustomerSearch("");
|
||||
setShowCustomerDropdown(false);
|
||||
};
|
||||
|
||||
const clearCustomer = () => {
|
||||
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
|
||||
};
|
||||
|
||||
const updateItem = (
|
||||
@@ -863,13 +801,7 @@ export default function OfferDetail() {
|
||||
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
|
||||
const total = subtotal + vatAmount;
|
||||
|
||||
const filteredCustomers = customerSearch
|
||||
? customers.filter((c) =>
|
||||
c.name.toLowerCase().includes(customerSearch.toLowerCase()),
|
||||
)
|
||||
: customers;
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (targetStatus?: string) => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
|
||||
if (!form.created_at) newErrors.created_at = "Datum je povinné";
|
||||
@@ -879,14 +811,28 @@ export default function OfferDetail() {
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
setSavingAction(targetStatus ?? "save");
|
||||
try {
|
||||
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
|
||||
const payload: any = {
|
||||
...form,
|
||||
items: items.map((item, i) => ({ ...item, position: i })),
|
||||
items: items.map((item, i) => ({
|
||||
...item,
|
||||
// Coerce the raw typed strings back to numbers so an empty/partial
|
||||
// field never reaches the server as NaN (mirrors the totals math).
|
||||
quantity: Number(item.quantity) || 0,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
position: i,
|
||||
})),
|
||||
sections: sections.map((s, i) => ({ ...s, position: i })),
|
||||
};
|
||||
if (!isEdit) delete payload.quotation_number;
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live offer sends no status — the backend keeps
|
||||
// its current status.
|
||||
if (targetStatus) payload.status = targetStatus;
|
||||
// The quotation number is NOT user-editable for drafts (it is NULL until
|
||||
// finalized). Strip it on create AND when finalizing an existing draft.
|
||||
if (!isEdit || offerStatus === "draft") delete payload.quotation_number;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
@@ -896,7 +842,9 @@ export default function OfferDetail() {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const offerId = isEdit ? id : result.data?.id;
|
||||
if (offerId) {
|
||||
// A draft has no number → skip the PDF save. Only generate/save the PDF
|
||||
// on a live create or a finalize (targetStatus !== "draft").
|
||||
if (offerId && targetStatus !== "draft") {
|
||||
await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch(
|
||||
() => {},
|
||||
);
|
||||
@@ -905,13 +853,6 @@ export default function OfferDetail() {
|
||||
result.message ||
|
||||
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
|
||||
);
|
||||
if (!isEdit) {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch (e) {
|
||||
console.error("Failed to remove offer draft:", e);
|
||||
}
|
||||
}
|
||||
if (!isEdit && result.data?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||
@@ -925,6 +866,16 @@ export default function OfferDetail() {
|
||||
items,
|
||||
sections,
|
||||
});
|
||||
// Finalizing a draft (draft → active) assigns the official number
|
||||
// server-side. Reflect the new status immediately and re-hydrate the
|
||||
// form from the refetched detail so the now-assigned number renders
|
||||
// (the local state already equals what was just persisted, so the
|
||||
// re-hydration is non-destructive).
|
||||
if (targetStatus && targetStatus !== "draft") {
|
||||
setOfferStatus(targetStatus);
|
||||
formInitializedRef.current = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["offers", id] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
@@ -937,6 +888,7 @@ export default function OfferDetail() {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1111,16 +1063,25 @@ export default function OfferDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
|
||||
{isEdit
|
||||
? `Nabídka ${documentNumberLabel(form.quotation_number)}`
|
||||
: "Nová nabídka"}
|
||||
</Typography>
|
||||
{isInvalidated && (
|
||||
<StatusChip label="Zneplatněna" color="error" />
|
||||
)}
|
||||
{isCompleted && <StatusChip label="Dokončeno" color="success" />}
|
||||
{isEdit &&
|
||||
(isCompleted ? (
|
||||
// Completed is derived from the linked order, not the offer's
|
||||
// own status — surface it (success) over the base chip.
|
||||
<StatusChip label="Dokončená" color="success" />
|
||||
) : (
|
||||
<StatusChip
|
||||
label={statusLabel(OFFER_STATUS, offerStatus)}
|
||||
color={statusColor(OFFER_STATUS, offerStatus)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
{isEdit && hasPermission("offers.export") && (
|
||||
{isEdit && hasPermission("offers.view") && (
|
||||
<Button
|
||||
onClick={handlePdf}
|
||||
variant="outlined"
|
||||
@@ -1167,17 +1128,101 @@ export default function OfferDetail() {
|
||||
<Button
|
||||
onClick={() => setInvalidateConfirm(true)}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
color="error"
|
||||
>
|
||||
Zneplatnit
|
||||
</Button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Ukládání..." : "Uložit"}
|
||||
{/* ── Create mode: two-button save ── */}
|
||||
{!isEdit && !readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => handleSave("draft")}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === "draft" ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
"Uložit koncept"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleSave(LIVE_STATUS)}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === LIVE_STATUS ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Edit mode, draft: save-draft + finalize (Aktivovat) ── */}
|
||||
{isEdit && !readOnly && offerStatus === "draft" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => handleSave("draft")}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === "draft" ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
"Uložit koncept"
|
||||
)}
|
||||
</Button>
|
||||
{hasPermission("offers.edit") && (
|
||||
<Button
|
||||
onClick={() => handleSave(LIVE_STATUS)}
|
||||
disabled={saving}
|
||||
>
|
||||
{savingAction === LIVE_STATUS ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Aktivovat"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEdit && hasPermission("offers.delete") && (
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Edit mode, live (non-draft) offer — unchanged plain save ── */}
|
||||
{isEdit && !readOnly && offerStatus !== "draft" && (
|
||||
<Button onClick={() => handleSave()} disabled={saving}>
|
||||
{savingAction === "save" ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && hasPermission("offers.delete") && (
|
||||
<Button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
variant="outlined"
|
||||
@@ -1255,110 +1300,14 @@ export default function OfferDetail() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zákazník" error={errors.customer_id} required>
|
||||
{form.customer_id ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ flex: 1 }}>
|
||||
{form.customer_name}
|
||||
</Box>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={clearCustomer}
|
||||
title="Odebrat zákazníka"
|
||||
aria-label="Odebrat zákazníka"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{ position: "relative" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TextField
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
disabled={readOnly}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka..."
|
||||
InputProps={{ readOnly }}
|
||||
/>
|
||||
{showCustomerDropdown && !isInvalidated && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
mt: 0.5,
|
||||
maxHeight: 280,
|
||||
overflowY: "auto",
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
boxShadow: 3,
|
||||
}}
|
||||
>
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<Box sx={{ px: 1.5, py: 1, color: "text.secondary" }}>
|
||||
Žádní zákazníci
|
||||
</Box>
|
||||
) : (
|
||||
filteredCustomers.slice(0, 20).map((c) => (
|
||||
<Box
|
||||
key={c.id}
|
||||
onMouseDown={() => selectCustomer(c)}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
cursor: "pointer",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
>
|
||||
<Box>{c.name}</Box>
|
||||
{c.city && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: "0.8rem",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{c.city}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
@@ -1448,7 +1397,7 @@ export default function OfferDetail() {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Sazba DPH (%)">
|
||||
<Field label="Sazba DPH">
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Select
|
||||
value={String(form.vat_rate)}
|
||||
@@ -1473,7 +1422,7 @@ export default function OfferDetail() {
|
||||
onChange={(e) => updateForm("apply_vat", e.target.checked)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Box component="span">Účtovat DPH</Box>
|
||||
<Box component="span">Uplatnit DPH</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Field>
|
||||
@@ -1609,16 +1558,20 @@ export default function OfferDetail() {
|
||||
#
|
||||
</TableCell>
|
||||
<TableCell>Popis</TableCell>
|
||||
<TableCell sx={{ width: "5rem" }}>Množství</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Množství
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5rem" }}>Jednotka</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }}>Cena/ks</TableCell>
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="right">
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
Celkem
|
||||
</TableCell>
|
||||
{!readOnly && <TableCell sx={{ width: "3rem" }} />}
|
||||
{!readOnly && <TableCell sx={{ width: "2.5rem" }} />}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
||||
@@ -4,17 +4,30 @@ import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
czechPlural,
|
||||
} from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
@@ -28,7 +41,6 @@ import {
|
||||
Select,
|
||||
StatusChip,
|
||||
FileUpload,
|
||||
Alert,
|
||||
EmptyState,
|
||||
FilterBar,
|
||||
Tabs,
|
||||
@@ -38,16 +50,24 @@ import {
|
||||
type DataColumn,
|
||||
type TabDef,
|
||||
} from "../ui";
|
||||
import {
|
||||
OFFER_STATUS,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
statusOptions,
|
||||
documentNumberLabel,
|
||||
} from "../lib/documentStatus";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
const DRAFT_KEY = "boha_offer_draft";
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: "", label: "Vše" },
|
||||
{ value: "active", label: "Aktivní" },
|
||||
{ value: "ordered", label: "Objednaná" },
|
||||
{ value: "invalidated", label: "Zneplatněná" },
|
||||
];
|
||||
// Filter tabs derive from OFFER_STATUS so `draft` ("Koncept") flows in
|
||||
// automatically; the leading "Všechny" tab is the all-statuses filter. The
|
||||
// `expired` entry in OFFER_STATUS is a front-end-derived state (not a stored
|
||||
// value), so it is excluded from the filter tabs.
|
||||
const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny",
|
||||
}).filter((o) => o.value !== "expired");
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
@@ -63,18 +83,6 @@ interface Quotation {
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
form: {
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
};
|
||||
items: unknown[];
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
const TemplatesIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -220,6 +228,92 @@ const DeleteIcon = (
|
||||
<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>
|
||||
);
|
||||
const MoreIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="19" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-row overflow menu for the secondary offer actions (Duplicate +
|
||||
* Zneplatnit). Hook state (anchorEl) lives HERE, in a child component — never
|
||||
* in the page-level `.map`/render callback — so each row owns its own menu
|
||||
* (no shared-open bug) and Rules of Hooks stay satisfied. The page passes in
|
||||
* the same handlers + permission/disabled flags the inline icons used, so
|
||||
* behavior/permissions/confirms are preserved verbatim; the items are merely
|
||||
* relocated. The menu renders nothing (returns null) when no secondary action
|
||||
* is available for the row.
|
||||
*/
|
||||
function RowActionsMenu({
|
||||
canDuplicate,
|
||||
duplicating,
|
||||
onDuplicate,
|
||||
canInvalidate,
|
||||
onInvalidate,
|
||||
}: {
|
||||
canDuplicate: boolean;
|
||||
duplicating: boolean;
|
||||
onDuplicate: () => void;
|
||||
canInvalidate: boolean;
|
||||
onInvalidate: () => void;
|
||||
}) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
if (!canDuplicate && !canInvalidate) return null;
|
||||
const close = () => setAnchorEl(null);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
aria-label="Další akce"
|
||||
title="Další akce"
|
||||
>
|
||||
{MoreIcon}
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
>
|
||||
{canDuplicate && (
|
||||
<MenuItem
|
||||
disabled={duplicating}
|
||||
onClick={() => {
|
||||
close();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{duplicating ? <CircularProgress size={16} /> : DuplicateIcon}
|
||||
</ListItemIcon>
|
||||
<ListItemText>Duplikovat</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canInvalidate && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
close();
|
||||
onInvalidate();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{InvalidateIcon}</ListItemIcon>
|
||||
<ListItemText>Zneplatnit</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Offers() {
|
||||
const alert = useAlert();
|
||||
@@ -232,6 +326,9 @@ export default function Offers() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [customerFilter, setCustomerFilter] = useState<number | "">("");
|
||||
// Track first successful load so later refetches (filter/customer/tab/page
|
||||
// change) keep the table visible instead of flashing the full skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const { data: customers } = useQuery(offerCustomersOptions());
|
||||
|
||||
@@ -264,17 +361,6 @@ export default function Offers() {
|
||||
}>({ show: false, quotation: null });
|
||||
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed;
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
@@ -293,6 +379,22 @@ export default function Offers() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-currency total over the WHOLE filtered set (not one page). Uses the
|
||||
// SAME filters as the list query so the summary matches what's shown.
|
||||
const statsQuery = useQuery(
|
||||
offerStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: statusFilter || undefined,
|
||||
customer_id: customerFilter || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mark first load done in an effect (not during render) to avoid a
|
||||
// render-phase ref mutation.
|
||||
useEffect(() => {
|
||||
if (!isPending) hasLoadedOnce.current = true;
|
||||
}, [isPending]);
|
||||
|
||||
const duplicateMutation = useApiMutation<
|
||||
number,
|
||||
{ message?: string; error?: string }
|
||||
@@ -331,15 +433,6 @@ export default function Offers() {
|
||||
},
|
||||
});
|
||||
|
||||
const discardDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
if (!hasPermission("offers.view")) return <Forbidden />;
|
||||
|
||||
const handleDuplicate = async (quotation: Quotation) => {
|
||||
@@ -450,7 +543,10 @@ export default function Offers() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||
// Card dims via isFetching) so it doesn't flash.
|
||||
if (isPending && !hasLoadedOnce.current) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
@@ -467,6 +563,10 @@ export default function Offers() {
|
||||
label: f.label,
|
||||
}));
|
||||
|
||||
// Search/status filter is active → an empty list means "nothing matches"
|
||||
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
|
||||
const isFiltered = !!debouncedSearch || !!statusFilter;
|
||||
|
||||
const columns: DataColumn<Quotation>[] = [
|
||||
{
|
||||
key: "quotation_number",
|
||||
@@ -483,7 +583,7 @@ export default function Offers() {
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{q.quotation_number}
|
||||
{documentNumberLabel(q.quotation_number)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
@@ -497,7 +597,7 @@ export default function Offers() {
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Zákazník",
|
||||
width: "18%",
|
||||
width: "13%",
|
||||
render: (q) => q.customer_name || "—",
|
||||
},
|
||||
{
|
||||
@@ -511,22 +611,43 @@ export default function Offers() {
|
||||
{
|
||||
key: "valid_until",
|
||||
header: "Platnost",
|
||||
width: "11%",
|
||||
width: "10%",
|
||||
sortKey: "valid_until",
|
||||
mono: true,
|
||||
render: (q) => formatDate(q.valid_until),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "10%",
|
||||
sortKey: "status",
|
||||
render: (q) => {
|
||||
// The offer's own status is `active`/`ordered`/`invalidated`. When its
|
||||
// linked order is completed, surface that as "Dokončená" (success) —
|
||||
// matching the OfferDetail header chip and the completed row tint.
|
||||
const completed =
|
||||
q.status !== "invalidated" && q.order_status === "dokoncena";
|
||||
return completed ? (
|
||||
<StatusChip label="Dokončená" color="success" />
|
||||
) : (
|
||||
<StatusChip
|
||||
label={statusLabel(OFFER_STATUS, q.status)}
|
||||
color={statusColor(OFFER_STATUS, q.status)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
header: "Měna",
|
||||
width: "8%",
|
||||
width: "7%",
|
||||
sortKey: "currency",
|
||||
render: (q) => <StatusChip label={q.currency} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "12%",
|
||||
width: "11%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
@@ -535,12 +656,22 @@ export default function Offers() {
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "16%",
|
||||
width: "15%",
|
||||
align: "right",
|
||||
render: (q) => {
|
||||
const isInvalidated = q.status === "invalidated";
|
||||
const isCompleted = !isInvalidated && q.order_status === "dokoncena";
|
||||
const readOnly = isInvalidated || isCompleted;
|
||||
// Secondary actions (relocated into the overflow menu). Same gates as
|
||||
// before: Duplicate needs offers.create and an editable (not
|
||||
// read-only) row; Zneplatnit needs offers.edit and a row that is not
|
||||
// invalidated/completed/ordered.
|
||||
const canDuplicate = !readOnly && hasPermission("offers.create");
|
||||
const canInvalidate =
|
||||
!isInvalidated &&
|
||||
!isCompleted &&
|
||||
!q.order_id &&
|
||||
hasPermission("offers.edit");
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
@@ -551,21 +682,6 @@ export default function Offers() {
|
||||
>
|
||||
{readOnly ? ViewIcon : EditIcon}
|
||||
</IconButton>
|
||||
{!readOnly && hasPermission("offers.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDuplicate(q)}
|
||||
aria-label="Duplikovat"
|
||||
title="Duplikovat"
|
||||
disabled={duplicating === q.id}
|
||||
>
|
||||
{duplicating === q.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
DuplicateIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{!readOnly && q.order_id ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -598,22 +714,7 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{!isInvalidated &&
|
||||
!isCompleted &&
|
||||
!q.order_id &&
|
||||
hasPermission("offers.edit") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setInvalidateConfirm({ show: true, quotation: q })
|
||||
}
|
||||
aria-label="Zneplatnit"
|
||||
title="Zneplatnit"
|
||||
>
|
||||
{InvalidateIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("offers.export") && (
|
||||
{hasPermission("offers.view") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
@@ -635,6 +736,15 @@ export default function Offers() {
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<RowActionsMenu
|
||||
canDuplicate={canDuplicate}
|
||||
duplicating={duplicating === q.id}
|
||||
onDuplicate={() => handleDuplicate(q)}
|
||||
canInvalidate={canInvalidate}
|
||||
onInvalidate={() =>
|
||||
setInvalidateConfirm({ show: true, quotation: q })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
@@ -714,7 +824,7 @@ export default function Offers() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
|
||||
<Tabs
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
@@ -755,79 +865,6 @@ export default function Offers() {
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{draft && !debouncedSearch && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert severity="info" onClose={discardDraft}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography component="span" sx={{ fontWeight: 600 }}>
|
||||
Rozpracovaný koncept
|
||||
</Typography>
|
||||
{draft.savedAt && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ ml: 1, opacity: 0.8, fontSize: "0.875rem" }}
|
||||
>
|
||||
·{" "}
|
||||
{new Date(draft.savedAt).toLocaleTimeString("cs-CZ", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "text.secondary", mt: 0.25 }}
|
||||
>
|
||||
{draft.form.project_code || "—"} ·{" "}
|
||||
{draft.form.customer_name || "—"} ·{" "}
|
||||
{draft.form.created_at
|
||||
? formatDate(draft.form.created_at)
|
||||
: "—"}
|
||||
{" — "}
|
||||
{draft.form.valid_until
|
||||
? formatDate(draft.form.valid_until)
|
||||
: "—"}
|
||||
{draft.form.currency ? ` · ${draft.form.currency}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/offers/new"
|
||||
size="small"
|
||||
startIcon={EditIcon}
|
||||
>
|
||||
Pokračovat v konceptu
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={discardDraft}
|
||||
>
|
||||
Zahodit koncept
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<Quotation>
|
||||
columns={columns}
|
||||
@@ -838,8 +875,11 @@ export default function Offers() {
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
debouncedSearch ? (
|
||||
<EmptyState title="Žádné nabídky odpovídající hledání." />
|
||||
isFiltered ? (
|
||||
<EmptyState
|
||||
title="Žádné nabídky neodpovídají filtru"
|
||||
description="Zkuste změnit filtr nebo hledaný výraz."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné nabídky."
|
||||
@@ -854,6 +894,27 @@ export default function Offers() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(statsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(statsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user