Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4258699b73 | ||
|
|
bfd2c59ad3 | ||
|
|
0b69dbfde0 | ||
|
|
db7a5c3d15 | ||
|
|
f1ce76d21d | ||
|
|
575c07aac8 | ||
|
|
4bf245d35c | ||
|
|
79d026e756 | ||
|
|
40a859f5e1 | ||
|
|
ffae1a4e74 | ||
|
|
5c9ebddf50 | ||
|
|
f268907848 | ||
|
|
dd8c699420 | ||
|
|
268a947c85 | ||
|
|
75dc97516e | ||
|
|
d1533ffc4d | ||
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 | ||
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 | ||
|
|
4e534471d9 | ||
|
|
975a555af5 | ||
|
|
6a22195c7d | ||
|
|
74ce24e3fa | ||
|
|
8ee5a443ef | ||
|
|
b3e2abf9b2 | ||
|
|
1826fc7976 |
@@ -1,14 +1,23 @@
|
||||
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
||||
let data = '';
|
||||
process.stdin.on('data', chunk => data += chunk);
|
||||
process.stdin.on('end', () => {
|
||||
//
|
||||
// Hook exit-code contract (Claude Code PreToolUse):
|
||||
// exit 0 = allow (stdout JSON is only parsed on exit 0)
|
||||
// exit 2 = BLOCK — the reason must be written to STDERR
|
||||
// any other exit code = non-blocking error (the tool call proceeds)
|
||||
// So to actually block, we must print to stderr and exit 2.
|
||||
let data = "";
|
||||
process.stdin.on("data", (chunk) => (data += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const input = JSON.parse(data);
|
||||
const filePath = input.tool_input?.file_path || '';
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
if (basename === '.env') {
|
||||
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
|
||||
process.exit(1);
|
||||
const filePath = input.tool_input?.file_path || "";
|
||||
const basename = filePath.split("/").pop().split("\\").pop();
|
||||
if (basename === ".env") {
|
||||
console.error("Direct .env edits are blocked. Use .env.example instead.");
|
||||
process.exit(2);
|
||||
}
|
||||
} catch {
|
||||
// Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2
|
||||
// here would block EVERY Edit/Write if the hook payload format changed.
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
749
CLAUDE.md
749
CLAUDE.md
@@ -1,25 +1,26 @@
|
||||
# CLAUDE.md — boha-app-ts
|
||||
|
||||
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
||||
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
|
||||
Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
||||
| HTTP Framework | Fastify 5.8.2 |
|
||||
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node.js, TypeScript (strict, all tsconfigs) |
|
||||
| HTTP Framework | Fastify 5 |
|
||||
| ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL |
|
||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| 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) |
|
||||
| Validation | Zod 4 |
|
||||
| Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
|
||||
| Testing | Vitest + Supertest against a real `app_test` DB |
|
||||
| PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
|
||||
| Email / Cron | nodemailer / node-cron |
|
||||
| AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
|
||||
|
||||
(Exact versions live in `package.json` — don't trust any pinned here.)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,34 +28,28 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.ts # Fastify server entry point — plugins, routes, error handler
|
||||
├── server.ts # Fastify entry — plugins, routes, error handler
|
||||
├── routes/admin/ # HTTP route handlers (one file per entity)
|
||||
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
|
||||
├── schemas/ # Zod validation schemas (one file per entity)
|
||||
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
|
||||
│ # security.ts (CSP, HSTS, security headers)
|
||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
||||
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
|
||||
├── services/ # Business logic (no classes, exported functions, own Prisma)
|
||||
├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
|
||||
├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
|
||||
├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
|
||||
├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
|
||||
├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
|
||||
├── admin/ # React 19 + MUI v7 frontend
|
||||
│ ├── 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
|
||||
│ ├── theme.ts / GlobalStyles.tsx
|
||||
│ ├── ui/ # MUI component kit — pages import from here
|
||||
│ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
|
||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||
│ ├── 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 (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
|
||||
│ ├── lib/queries/ # React Query options + useApiMutation
|
||||
│ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
|
||||
│ └── utils/ # api.ts (apiFetch with token refresh), formatters
|
||||
└── __tests__/ # Vitest suites (server-side only; real app_test DB)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 52 models, MySQL, snake_case columns
|
||||
└── migrations/ # Applied migrations
|
||||
|
||||
dist/ # Compiled server (CommonJS, ES2022)
|
||||
dist-client/ # Built frontend (Vite, ES2020)
|
||||
prisma/ # schema.prisma (snake_case columns) + migrations/
|
||||
dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -62,502 +57,364 @@ dist-client/ # Built frontend (Vite, ES2020)
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Starts server in watch mode (manage frontend separately)
|
||||
npm run dev:server # tsx watch src/server.ts
|
||||
npm run dev:client # Vite dev server
|
||||
|
||||
# Build
|
||||
npm run build # Build server + client
|
||||
npm run build:server # tsc -p tsconfig.server.json → dist/
|
||||
npm run build:client # vite build → dist-client/
|
||||
|
||||
# Run (production)
|
||||
npm start # node dist/server.js
|
||||
|
||||
# Tests
|
||||
npm test # vitest run (single pass) — 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 dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
|
||||
npm run build # server + client
|
||||
npm test # vitest run — against app_test via .env.test
|
||||
npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing)
|
||||
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 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
|
||||
npx prisma migrate deploy # Apply pending migrations to production
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
npx prisma studio # DB browser GUI
|
||||
npx prisma db seed # (Re)seed the dev database
|
||||
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
|
||||
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
|
||||
npx prisma generate # after every schema change (client is not committed)
|
||||
npx prisma studio # DB browser
|
||||
```
|
||||
|
||||
**Do not start the dev server.** The user manages it separately.
|
||||
|
||||
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
|
||||
**Before any migration, ask the user to stop their dev server and wait for
|
||||
confirmation** (the running server holds DB connections).
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
|
||||
|
||||
```
|
||||
DATABASE_URL=mysql://user:pass@host:3306/dbname
|
||||
JWT_SECRET=<64-char hex string>
|
||||
TOTP_ENCRYPTION_KEY=<64-char hex string>
|
||||
```
|
||||
Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
|
||||
hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
|
||||
`ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
|
||||
`REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files),
|
||||
`NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees),
|
||||
`MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`,
|
||||
`APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it).
|
||||
|
||||
Optional (with defaults):
|
||||
|
||||
```
|
||||
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
||||
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
|
||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
|
||||
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
|
||||
MAX_UPLOAD_SIZE=52428800 # 50MB
|
||||
CONTACT_EMAIL_TO=
|
||||
CONTACT_EMAIL_FROM=
|
||||
SMTP_FROM=
|
||||
LEAVE_NOTIFY_EMAIL=
|
||||
APP_URL= # Used in email links
|
||||
CORS_ORIGINS= # Comma-separated, production only
|
||||
```
|
||||
|
||||
Use `.env` for dev, `.env.test` for tests.
|
||||
`.env` for dev, `.env.test` for tests (both gitignored — **never edit env
|
||||
files; the user manages them**).
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Key Patterns
|
||||
|
||||
### Request Flow
|
||||
### Request flow
|
||||
|
||||
```
|
||||
Request → CORS → Cookie → Rate-limit → Security headers
|
||||
→ requirePermission() or requireAuth()
|
||||
→ Zod schema validation (parseBody helper)
|
||||
→ Route handler
|
||||
→ Service function
|
||||
→ Prisma
|
||||
→ success(reply, data) or error(reply, message, status)
|
||||
→ requirePermission() / requireAnyPermission()
|
||||
→ parseBody(Schema) / parseId(param)
|
||||
→ Route handler → Service function → Prisma
|
||||
→ success(reply, data) | error(reply, czechMessage, status)
|
||||
```
|
||||
|
||||
### Response Format
|
||||
### Response format
|
||||
|
||||
All responses use this shape:
|
||||
`{ success: true, data, message?, pagination? }` on success,
|
||||
`{ success: false, error }` on failure. Always the `success()`/`error()`/
|
||||
paginated helpers — never raw `reply.send()`.
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
{ success: true, data: T, message?: string, pagination?: {...} }
|
||||
### Services
|
||||
|
||||
// Error
|
||||
{ success: false, error: string }
|
||||
```
|
||||
|
||||
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
|
||||
|
||||
### Service Pattern
|
||||
|
||||
Services are plain exported async functions, no classes:
|
||||
|
||||
```typescript
|
||||
// src/services/foo.service.ts
|
||||
export async function getFoo(id: number) {
|
||||
const result = await prisma.foo.findUnique({ where: { id } });
|
||||
if (!result) return { error: "Not found", status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
// src/routes/admin/foo.ts
|
||||
const result = await getFoo(id);
|
||||
if ("error" in result) return error(reply, result.error, result.status ?? 400);
|
||||
return success(reply, result.data);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Routes map service errors to HTTP responses using the pattern above.
|
||||
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
|
||||
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
|
||||
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
|
||||
Plain exported async functions; services own Prisma, routes stay thin.
|
||||
Preferred error shape: return **token literals** (`"not_found"`,
|
||||
`"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map
|
||||
tokens to Czech messages + HTTP codes (the offers/issued-orders modules are
|
||||
the reference). Legacy services return `{ error: czech, status }` — fine to
|
||||
keep until touched; never the discriminated-union style. Respect soft-delete
|
||||
(`is_deleted: false`) in reads where the model has it.
|
||||
|
||||
### Permissions
|
||||
|
||||
```typescript
|
||||
// Route-level guard
|
||||
fastify.addHook("preHandler", requirePermission("invoices.view"));
|
||||
// or multiple
|
||||
fastify.addHook(
|
||||
"preHandler",
|
||||
requirePermission("invoices.view", "invoices.edit"),
|
||||
);
|
||||
`requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
|
||||
role bypasses checks (shortcut: `authData.roleName === "admin"` —
|
||||
⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
|
||||
GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
|
||||
(bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
|
||||
permission opens pages read-only; an _edit_ permission unlocks fields and
|
||||
save buttons.
|
||||
|
||||
// Admin role bypasses all permission checks
|
||||
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
|
||||
```
|
||||
### Audit
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
|
||||
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
|
||||
|
||||
### Validation
|
||||
|
||||
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
|
||||
|
||||
```typescript
|
||||
const body = parseBody(FooSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
```
|
||||
`logAudit()` on every create/update/delete (and security-relevant actions)
|
||||
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
|
||||
descriptions for unnumbered drafts. Audit failures are non-fatal.
|
||||
|
||||
---
|
||||
|
||||
## Date & Timezone Handling (Critical Gotcha)
|
||||
## Dates & Timezones (Critical — three rules)
|
||||
|
||||
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
|
||||
`Date.prototype.toJSON()` to return local time (not UTC). This means:
|
||||
1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
|
||||
monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration
|
||||
parity). All API date strings are local. Never assume UTC; never manually
|
||||
offset. Do not remove the override.
|
||||
2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date
|
||||
part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of
|
||||
the _previous_ day) silently shifts the queried window a day back — this was
|
||||
a 14-site production bug class. Build `@db.Date` filter boundaries and
|
||||
"today" writes as UTC-midnight instants of the LOCAL calendar day:
|
||||
`new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from
|
||||
`src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare
|
||||
`new Date()` into a `@db.Date` column (stores yesterday between 00:00–02:00
|
||||
Prague).
|
||||
3. **Two deliberate write regimes — don't "fix" either:** the plan module
|
||||
does date-only math in UTC; attendance/leave writes `@db.Date` at **local
|
||||
noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from
|
||||
`localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) —
|
||||
**never** `new Date().toISOString().split("T")[0]` (UTC date, a day early
|
||||
in the Prague evening).
|
||||
|
||||
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
|
||||
- All API responses with Date fields will contain local time strings.
|
||||
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
|
||||
- **Never assume UTC** when working with Date objects in this codebase.
|
||||
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
|
||||
- The override exists for PHP migration compatibility and Czech date display.
|
||||
---
|
||||
|
||||
## Document Modules (offers · orders · issued orders · invoices)
|
||||
|
||||
**Business rules (user/accountant decisions — do not revert):**
|
||||
|
||||
- **VAT exists ONLY on invoices and received invoices.** Offers, order
|
||||
confirmations and issued orders are NOT tax documents: net prices only, one
|
||||
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
|
||||
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
|
||||
from an order prefill the company default VAT rate.
|
||||
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
|
||||
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
|
||||
offers take customers. Issued orders share the `orders._` permission family
|
||||
(deliberate).
|
||||
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
|
||||
design; invoices + issued orders share the red-accent (#de3a3a) family.
|
||||
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
|
||||
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
|
||||
orders deliberately have NO scope-template picker, NO item templates, NO
|
||||
duplicate action.
|
||||
|
||||
**Platform conventions:**
|
||||
|
||||
- **Deferred numbering:** drafts carry a NULL document number
|
||||
(nullable-unique column); the sequence number is consumed in-transaction on
|
||||
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
|
||||
released-if-highest on delete. Never hardcode numbering; previews use the
|
||||
collision-advancing helpers.
|
||||
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
|
||||
responses return `valid_transitions` and the UI renders transition buttons
|
||||
from it. Field edits outside the editable states (offers: draft/active;
|
||||
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
|
||||
payloads still pass.
|
||||
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
|
||||
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
|
||||
enriches `locked_by {user_id, username, full_name}` only when fresh and
|
||||
held by another user; client side is the shared `useDocumentLock` hook +
|
||||
`LockBanner`.
|
||||
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
|
||||
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
|
||||
so the UI hides PDF buttons on drafts. Numbered-document archives write to
|
||||
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||
suffixes. The issued-order PDF gets language from the document's `language`
|
||||
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
|
||||
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
|
||||
- **Shared modules — extend, never fork local copies:**
|
||||
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
|
||||
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
|
||||
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
|
||||
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
|
||||
diacritics in headers make Node throw 500s).
|
||||
|
||||
---
|
||||
|
||||
## TOTP / 2FA
|
||||
|
||||
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
|
||||
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
|
||||
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
|
||||
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
|
||||
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
|
||||
Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
|
||||
hex formats both supported); encrypted backup codes with their own
|
||||
`/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
|
||||
enrollment. Login flow: password → (if enrolled) single-use 5-min
|
||||
`loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
|
||||
QR is generated locally with the `qrcode` package (never an external QR URL —
|
||||
CSP blocks it and it would leak the secret).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
|
||||
|
||||
- **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; `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`.
|
||||
- The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
|
||||
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
|
||||
unless `DATABASE_URL` names a `*test*` database.
|
||||
- **After any schema change, apply migrations to the test DB too** or the
|
||||
suite fails: set `DATABASE_URL` to the app_test URL and run
|
||||
`npx prisma migrate deploy`.
|
||||
- To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
|
||||
with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
|
||||
`npx tsx prisma/seed.ts`.
|
||||
- **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
|
||||
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
|
||||
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
|
||||
sequence deadlocks. Tests are type-checked by `tsc -b`.
|
||||
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
|
||||
cleanup; FK-safe deletion order. New features get tests in
|
||||
`src/__tests__/<feature>.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
||||
- Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
|
||||
toasts via `useAlert()`.
|
||||
- **React Query for all fetching** (query options live in
|
||||
`src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
|
||||
effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
|
||||
server message through). `apiFetch` refreshes tokens proactively.
|
||||
- **Rules of Hooks:** every hook runs BEFORE any early return — the
|
||||
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
|
||||
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
|
||||
— prefix matching covers sub-queries), and a mutation invalidates every
|
||||
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
|
||||
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
|
||||
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
|
||||
still must invalidate their domains.
|
||||
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
|
||||
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
|
||||
DB. Don't duplicate label maps.
|
||||
|
||||
### Query Invalidation Convention
|
||||
### Styling (MUI v7 — no custom .css, no Tailwind)
|
||||
|
||||
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
|
||||
|
||||
- `["users"]` over `["users", "list"]`
|
||||
- `["trips"]` over `["trips", "vehicles"]`
|
||||
|
||||
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
|
||||
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
|
||||
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
|
||||
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
|
||||
|
||||
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
|
||||
|
||||
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
|
||||
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
|
||||
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
|
||||
- Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
|
||||
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/` —
|
||||
reach for raw `@mui/material` only for one-off layout/infra.
|
||||
- Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
|
||||
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
|
||||
one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
|
||||
- Don't regress: status row tints are channel-alpha washes (never `.light`
|
||||
fills — invisible text in dark mode); icon badges are solid `X.main` +
|
||||
white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
|
||||
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
|
||||
`useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
|
||||
fade; ConfirmDialogs stay open with `loading` during their request.
|
||||
- When refactoring pages, **preserve ALL data logic verbatim** (hooks,
|
||||
mutations, invalidate arrays, validation, permissions).
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
||||
## AI Assistant — "Odin"
|
||||
|
||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
||||
|
||||
**Where styling lives**
|
||||
|
||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
||||
|
||||
**Building / editing pages**
|
||||
|
||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
||||
|
||||
**Conventions learned — don't regress**
|
||||
|
||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
||||
- **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`, `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`).
|
||||
Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
|
||||
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
|
||||
(text-only messages get a canned reply, no API call). Backend
|
||||
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger
|
||||
in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
|
||||
PDF → `extract-invoices` (structured output) → review cards → existing
|
||||
`POST /received-invoices`. **`received_invoices.amount` is GROSS
|
||||
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
|
||||
notes live in `docs/superpowers/specs/`.
|
||||
|
||||
---
|
||||
|
||||
## AI Assistant — "Odin" (shipped v2.1.5)
|
||||
## Database & Migrations
|
||||
|
||||
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.
|
||||
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
|
||||
soft-delete via `is_deleted` where present; `number_sequences` owns all
|
||||
document numbering.
|
||||
|
||||
- **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/`.
|
||||
**Golden rules:**
|
||||
|
||||
---
|
||||
- **NEVER `prisma db push`** — it bypasses migration history and causes drift.
|
||||
- **Every DB change is a tracked migration** — schema, permission/seed rows,
|
||||
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
|
||||
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
|
||||
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
|
||||
`APP_ENV=production`) — prod baselines belong in migrations.
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
|
||||
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
|
||||
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
|
||||
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations (Critical Workflow)
|
||||
|
||||
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
|
||||
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
|
||||
|
||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
||||
|
||||
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
|
||||
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
|
||||
- Lookup data, default settings, or any other row-level baseline that production needs
|
||||
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
|
||||
|
||||
**What is NOT acceptable:**
|
||||
|
||||
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
|
||||
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
|
||||
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
|
||||
|
||||
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
|
||||
|
||||
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
|
||||
|
||||
### Making schema changes
|
||||
|
||||
```
|
||||
1. Edit prisma/schema.prisma
|
||||
2. npx prisma migrate dev --name descriptive_name
|
||||
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
|
||||
3. npx prisma generate
|
||||
4. Commit BOTH schema.prisma AND the new migration folder
|
||||
git add prisma/schema.prisma prisma/migrations/
|
||||
```
|
||||
|
||||
### Verifying before production deploy
|
||||
**Making a schema change (this shell is non-interactive — `prisma migrate
|
||||
dev` will refuse to run; use this recipe):**
|
||||
|
||||
```bash
|
||||
# Preview what SQL will run on production (no changes applied)
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://user:pass@prod:3306/app" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# Empty output = no diff. If it shows SQL, review it before deploying.
|
||||
# 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
|
||||
# 2. Generate the migration SQL into a new folder:
|
||||
mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
|
||||
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||
> prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
|
||||
# 3. Review the SQL, then apply + regenerate:
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
# 4. Apply to the test DB as well (suite breaks otherwise):
|
||||
# DATABASE_URL=<app_test url> npx prisma migrate deploy
|
||||
# 5. Commit schema.prisma AND the migration folder together.
|
||||
```
|
||||
|
||||
### Deploying migrations to production
|
||||
|
||||
The release process runs `prisma migrate deploy` on production.
|
||||
This applies only pending migrations — safe, idempotent.
|
||||
|
||||
### If migrations get out of sync (drift)
|
||||
|
||||
```bash
|
||||
# Diff production DB against local schema to find drift
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://prod" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
|
||||
# If drift includes DROPs: investigate before touching production
|
||||
```
|
||||
|
||||
### Baselinining a database that has no migrations
|
||||
|
||||
If production was synced with `db push` and has no `_prisma_migrations` table:
|
||||
|
||||
```bash
|
||||
# 1. Create initial migration locally
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 2. Copy to production and mark as applied (no SQL runs)
|
||||
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
|
||||
ssh user@prod
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate resolve --applied <migration_name>
|
||||
```
|
||||
Deployment, drift checks, hotfix-migration shipping and baselining:
|
||||
see **`docs/release.md`**.
|
||||
|
||||
---
|
||||
|
||||
## Conventions (enforced — verified by the 2026-06-06 audit)
|
||||
|
||||
These are the unified rules across the codebase. Follow them for new code; the
|
||||
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
|
||||
## Enforced Conventions (single source — merged 2026-06 audits)
|
||||
|
||||
**Routes**
|
||||
|
||||
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
|
||||
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
|
||||
- **Bodies:** validate every body with `parseBody(Schema, request.body)` → `if ("error" in body) return error(reply, body.error, 400)`.
|
||||
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
|
||||
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
|
||||
|
||||
**Services**
|
||||
|
||||
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
|
||||
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
|
||||
- `success()`/`error()`/paginated helpers only; `parseId` for numeric params
|
||||
(`if (id === null) return;`); `parseBody` for every body; permission guards
|
||||
on reads AND writes; `logAudit` with old/new values on every mutation.
|
||||
- Role-management writes are admin-only and re-checked (no creating or
|
||||
rewriting the `admin` role).
|
||||
|
||||
**Schemas (Zod 4)**
|
||||
|
||||
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
|
||||
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
|
||||
- User-facing messages in **Czech**; identifiers/keys in English.
|
||||
- `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
|
||||
- **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
|
||||
form-coerced fields: `numberFromForm`, `numberInRange`,
|
||||
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
|
||||
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
|
||||
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
|
||||
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
|
||||
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
|
||||
the single biggest historical bug class). FK ids → intIdFromForm;
|
||||
quantities/prices → nonNegative/positive; bounded → numberInRange.
|
||||
- The date/time helpers are deliberately **lenient** (strip trailing time
|
||||
components) because edit forms re-submit `toJSON`-serialized values.
|
||||
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
|
||||
- Align `max()` caps with the DB column widths (an over-cap string 500s at
|
||||
Prisma instead of 400ing at Zod). Update schemas derive via
|
||||
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
|
||||
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
|
||||
still injects field defaults into partial payloads).
|
||||
- User-facing messages in **Czech**; identifiers/tokens in English.
|
||||
|
||||
**Frontend**
|
||||
**Determinism & concurrency**
|
||||
|
||||
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
|
||||
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
|
||||
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
|
||||
- Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
|
||||
columns make single-key sorts non-deterministic).
|
||||
- Read-modify-write of shared rows (stock, balances, sequences) runs inside
|
||||
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
|
||||
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
|
||||
writes (header + items + sections) belong in ONE transaction.
|
||||
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
|
||||
the checker) and catch `P2002` → 409 as backstop.
|
||||
|
||||
**Dates (two deliberate regimes — don't "fix" either)**
|
||||
**Errors**
|
||||
|
||||
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
|
||||
- **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.
|
||||
- Never silently swallow — every catch logs (`console.*` in services; there
|
||||
is no request-scoped logger there). The only allowed silent catch is an
|
||||
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
|
||||
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Gotchas
|
||||
## Known Gotchas
|
||||
|
||||
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
|
||||
|
||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
||||
|
||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
||||
|
||||
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
|
||||
|
||||
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
|
||||
|
||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
||||
|
||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
||||
|
||||
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
|
||||
|
||||
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
||||
|
||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
||||
|
||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
||||
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
|
||||
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
|
||||
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
|
||||
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
|
||||
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
|
||||
pass unsanitized user data into Puppeteer templates; string-built HTML
|
||||
(print views, PDF templates) must `escapeHtml` every interpolation.
|
||||
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
|
||||
with logged errors, and tests stub NAS reads.
|
||||
4. **Prisma client**: regenerate after every schema change; it is not
|
||||
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
|
||||
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
|
||||
6. **Czech locale hardcoded** in messages/month names — intentional.
|
||||
7. **Cross-login caches:** React Query keys are user-agnostic; the query
|
||||
cache is cleared on login/logout in AuthContext — keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
## Release
|
||||
|
||||
1. Bump version in `package.json`
|
||||
2. `npm run build`
|
||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||
- Path: `/var/www/app-ts`
|
||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||
- Install dependencies: `npm install --omit=dev`
|
||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||
- Restart: `pm2 restart app-ts --update-env`
|
||||
|
||||
### Hotfixing a migration that was added after the tarball was built
|
||||
|
||||
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
|
||||
release tarball has already been built and shipped, that migration is
|
||||
NOT in the tarball and `prisma migrate deploy` on prod will report
|
||||
"No pending migrations". The release tarball re-build re-runs the whole
|
||||
release, but for a one-off hotfix you can ship just the new migration
|
||||
folder:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
The migration is still tracked in git and applied via `prisma migrate
|
||||
deploy` — the only thing the tarball is doing is delivering the
|
||||
`migration.sql` to prod, which is the same mechanism the main release
|
||||
uses. This is preferred over running raw SQL on prod (which the policy
|
||||
in "Database Migrations" forbids).
|
||||
|
||||
Do not push directly to production or restart services without confirmation.
|
||||
Process, deployment commands, hotfix-migration shipping and verification
|
||||
checklist: **`docs/release.md`**. Run the full test suite before tagging; the
|
||||
user picks version numbers. **Never push to production or restart services
|
||||
without explicit confirmation.**
|
||||
|
||||
96
docs/release.md
Normal file
96
docs/release.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Release & deployment runbook — boha-app-ts
|
||||
|
||||
Referenced from CLAUDE.md. **Never deploy to production or restart services
|
||||
without explicit user confirmation.**
|
||||
|
||||
## Standard release
|
||||
|
||||
1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`,
|
||||
`npm run lint` (0 errors).
|
||||
2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number).
|
||||
3. `npm run build`
|
||||
4. Commit and tag: `git tag -a vX.Y.Z`
|
||||
5. Push to Gitea: `git push origin master && git push origin vX.Y.Z`
|
||||
6. Create tarball:
|
||||
|
||||
```bash
|
||||
tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts
|
||||
```
|
||||
|
||||
⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||
without it, `prisma generate`/`migrate deploy` on prod have no datasource.
|
||||
|
||||
7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`):
|
||||
|
||||
```bash
|
||||
scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts
|
||||
rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
|
||||
tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
|
||||
npm install --omit=dev
|
||||
npx prisma generate # MANDATORY — see below
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
**`npx prisma generate` is mandatory.** `npm install` skips client
|
||||
regeneration when dependencies didn't change, leaving a stale client that
|
||||
still selects dropped/renamed columns → P2022 500s in prod (this caused
|
||||
the v2.4.0 incident: every issued-orders query failed until generate ran).
|
||||
|
||||
8. Verify: `pm2 list` (status online, correct version, restart counter not
|
||||
climbing), `npx prisma migrate status` ("up to date"),
|
||||
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200,
|
||||
and grep logs for errors from the CURRENT pid only (the out log keeps old
|
||||
errors from prior pids).
|
||||
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||
|
||||
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||
constraint names the migration DROPs, check no data violates new
|
||||
UNIQUE/RESTRICT constraints — and confirm with the user before the
|
||||
irreversible steps. The prod DB is backed up by a cron job (no manual
|
||||
mysqldump needed).
|
||||
|
||||
## Hotfixing a migration added after the tarball was built
|
||||
|
||||
A migration folder created after the release tarball shipped is not on prod,
|
||||
so `prisma migrate deploy` reports "No pending migrations". Ship just the
|
||||
migration folder (still tracked in git — this is NOT raw SQL on prod):
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/<name>_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/<name>_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
tar -xzf /tmp/<name>_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
## Drift check / preview before deploy
|
||||
|
||||
```bash
|
||||
# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema
|
||||
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
|
||||
```
|
||||
|
||||
Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another
|
||||
DB need its URL in a config/env override.) If drift includes DROPs,
|
||||
investigate before touching production.
|
||||
|
||||
## Baselining a database that has no migrations
|
||||
|
||||
If a database was synced without migration history (no `_prisma_migrations`
|
||||
table): create the initial migration locally, copy `prisma/migrations` to the
|
||||
server, then mark it applied without running SQL:
|
||||
|
||||
```bash
|
||||
npx prisma migrate resolve --applied <migration_name>
|
||||
```
|
||||
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings
|
||||
|
||||
Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass:
|
||||
the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency
|
||||
finding. The two project-level HIGH _structural refactors_ (service-layer extraction for
|
||||
6 route files; PDF template dedup) are explicitly **out of scope** — they are
|
||||
multi-thousand-line refactors that deserve their own pass.
|
||||
|
||||
Execution: subagent-driven development — one implementer per task (sequential, shared
|
||||
test DB forbids parallel test runs), spec-compliance review then code-quality review
|
||||
after each. No commits until the user asks. Final full gates:
|
||||
`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`.
|
||||
|
||||
---
|
||||
|
||||
## Task H — Dependencies (project-level CRITICAL) — done inline by controller
|
||||
|
||||
- Remove `concurrently` from devDependencies (unused; carries both critical advisories
|
||||
GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1).
|
||||
- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate
|
||||
advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade).
|
||||
- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom`
|
||||
to devDependencies.
|
||||
- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external
|
||||
QR service).
|
||||
- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck.
|
||||
|
||||
## Task A — Attendance create/edit broken since 519edce (top-5 #1)
|
||||
|
||||
Findings (both verified HIGH):
|
||||
|
||||
- `src/admin/pages/AttendanceCreate.tsx:80-99` — handleSubmit posts the raw form whose
|
||||
shape the API does not accept: `arrival_date`/`break_start_date`/`break_start_time`/
|
||||
`break_end_*`/`departure_date` are not in CreateAttendanceSchema (silently stripped —
|
||||
breaks and overnight dates never save); times are never combined with dates (unlike
|
||||
useAttendanceAdmin's combineDatetime), so a validated "HH:MM" would reach
|
||||
createAttendance which does `new Date('08:00')` → Invalid Date → 500; and since commit
|
||||
519edce the always-sent empty-string time fields (`arrival_time: ""`) fail timeString
|
||||
validation, so EVERY submit from this page — including leave records — returns 400.
|
||||
- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) —
|
||||
create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for
|
||||
arrival/departure/break, but since 519edce CreateAttendanceSchema/UpdateAttendanceSchema
|
||||
validate them with `timeString` (HH:MM only) — every create/edit containing a time is
|
||||
rejected 400 "Čas musí být ve formátu HH:MM" (and the service itself expects full
|
||||
datetimes via `new Date(...)`); additionally `break_start`/`break_end` are absent from
|
||||
CreateAttendanceSchema, so breaks are silently stripped on create.
|
||||
|
||||
Direction: first `git show 519edce` to understand the intent; the schema contradicts
|
||||
both client and service, so the fix is to make the schemas validate the combined
|
||||
local-datetime wire format the client sends and the service consumes (lenient helper in
|
||||
`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create
|
||||
schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty
|
||||
optionals. TDD: route-level regression tests (work record with times+breaks, overnight,
|
||||
leave record without times, update) in `src/__tests__/`.
|
||||
|
||||
## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA
|
||||
always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server
|
||||
ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400.
|
||||
The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never
|
||||
called anywhere in the frontend — lockout for any user who lost their authenticator.
|
||||
- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `<img>` from api.qrserver.com
|
||||
is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA
|
||||
enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a
|
||||
third-party URL.) Fix: generate the QR locally with the `qrcode` package
|
||||
(`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed.
|
||||
- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions()
|
||||
which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the
|
||||
per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and
|
||||
wrong button shown.
|
||||
|
||||
Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code
|
||||
goes to backup-verify and completes the login-token flow. If the server endpoint doesn't
|
||||
fit the loginToken flow, extend it minimally (single-use code consumption + logAudit
|
||||
preserved). Server test for backup-verify login path if server is touched.
|
||||
|
||||
## Task C — Invoice/issued-order edit integrity (top-5 #3)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's
|
||||
persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`)
|
||||
though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice
|
||||
silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21%
|
||||
(same falsy-zero at line 689).
|
||||
- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is
|
||||
silently lost: payload includes billing_text but UpdateInvoiceSchema
|
||||
(src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it
|
||||
(updateInvoice supports it via strFields). Add the field to the schema + server test.
|
||||
- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks:
|
||||
`quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate)
|
||||
|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and
|
||||
silently persisted on next save.
|
||||
- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the
|
||||
new status into form.status, so StatusChip, the `editable` flag and the delete-button
|
||||
gate use the stale status after every transition.
|
||||
|
||||
Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x);
|
||||
Number.isFinite(n) ? n : fallback`).
|
||||
|
||||
## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated
|
||||
endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit
|
||||
25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no
|
||||
pagination controls.
|
||||
- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page →
|
||||
25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows.
|
||||
- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page;
|
||||
header labels stats "current month" though the query has no month filter.
|
||||
- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but
|
||||
UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT
|
||||
handler never applies it — vehicle change silently dropped. Add to schema
|
||||
(nullableIntIdFromForm) + apply in route + audit + server test.
|
||||
- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the
|
||||
month view and its stat cards.
|
||||
|
||||
Direction for totals: prefer a server-side aggregate (the repo already has per-currency
|
||||
totals endpoints for invoices/orders as the pattern) over fetching all pages client-side;
|
||||
lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces
|
||||
used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must
|
||||
cover the WHOLE month, not one page).
|
||||
|
||||
## Task E — orders.service.ts: PDF blob in every response (top-5 #4b)
|
||||
|
||||
Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683`
|
||||
— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit
|
||||
and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob
|
||||
for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the
|
||||
blob to check status/number. A dedicated /:id/attachment endpoint already exists.
|
||||
Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if
|
||||
the FE needs it — check FE usage), keep the attachment endpoint working, extend
|
||||
`src/__tests__/orders-list.test.ts` to assert attachment_data is absent.
|
||||
|
||||
## Task F — Warehouse: unit enum 400s + dead attachment download
|
||||
|
||||
Findings (both verified HIGH):
|
||||
|
||||
- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the
|
||||
server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value
|
||||
400s create/update. Make it a Select over the enum values (single source: mirror the
|
||||
server enum list).
|
||||
- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets
|
||||
GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST
|
||||
upload + DELETE are registered in warehouse.ts), and a plain <a href> sends no
|
||||
Authorization header anyway. Add the GET download route (requirePermission, correct
|
||||
content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch →
|
||||
object URL, like other binary downloads in the repo). Server test for the new route.
|
||||
|
||||
## Task G — Small fixes
|
||||
|
||||
- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes
|
||||
effect: prints {decision:'block'} JSON to stdout then exits 1; Claude Code only parses
|
||||
stdout JSON on exit 0 and only blocks on exit 2 (reason on stderr). Fix: exit 2 with the
|
||||
reason on stderr.
|
||||
- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits
|
||||
start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating
|
||||
a project without filling BOTH dates always 400s. Send null/omit when empty.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the
|
||||
2 known-mitigated quill lows remain
|
||||
- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten
|
||||
AttendanceCreate payload; 6 regression tests
|
||||
- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via
|
||||
qrcode lib, per-user totp status; 6 tests
|
||||
- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts),
|
||||
billing_text in UpdateInvoiceSchema, status mirror; 2 tests
|
||||
- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats
|
||||
(buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute,
|
||||
print rebuilt (sync window.open, escaped string template); 7 tests
|
||||
- [x] E — orders attachment_data excluded from all non-binary reads (incl.
|
||||
numbering/invoices/orders-pdf callers); 4 tests
|
||||
- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob
|
||||
download; RFC 5987 content-disposition helper (also fixes received-invoices +
|
||||
orders Czech-filename 500); 6 tests
|
||||
- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates
|
||||
→ null
|
||||
- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors ·
|
||||
build OK. Whole-diff 3-lens review: see final report.
|
||||
155
package-lock.json
generated
155
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.1.5",
|
||||
"version": "2.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.1.5",
|
||||
"version": "2.4.7",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
@@ -26,7 +26,6 @@
|
||||
"@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",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -52,8 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.5.0",
|
||||
@@ -64,7 +62,6 @@
|
||||
"@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",
|
||||
@@ -1729,9 +1726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -3010,13 +3007,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -3042,16 +3032,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -3076,6 +3056,7 @@
|
||||
"version": "28.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
|
||||
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -3088,6 +3069,7 @@
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
|
||||
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3236,14 +3218,15 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
@@ -4154,36 +4137,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
@@ -4297,31 +4250,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -4713,6 +4641,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -5835,16 +5764,6 @@
|
||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -7258,6 +7177,7 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
@@ -8087,16 +8007,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -8244,19 +8154,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -8577,22 +8474,6 @@
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -8817,16 +8698,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.2.0",
|
||||
"version": "2.4.7",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -14,7 +14,6 @@
|
||||
"preview": "vite preview",
|
||||
"db:generate": "prisma generate",
|
||||
"db:pull": "prisma db pull",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -47,7 +46,6 @@
|
||||
"@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",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -73,8 +71,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.5.0",
|
||||
@@ -85,7 +82,6 @@
|
||||
"@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",
|
||||
@@ -98,5 +94,8 @@
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "^1.19.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
|
||||
ADD COLUMN `supplier_id` INTEGER NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
|
||||
ADD COLUMN `locked_by` INTEGER NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_order_sections` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issued_order_id` INTEGER NOT NULL,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
`title` VARCHAR(500) NULL,
|
||||
`title_cz` VARCHAR(500) NULL,
|
||||
`content` TEXT NULL,
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `issued_order_sections_order_id`(`issued_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||
DROP COLUMN `issued_by`,
|
||||
DROP COLUMN `notes`,
|
||||
DROP COLUMN `payment_terms`;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
|
||||
-- the printed notes block; notes become internal-only. Existing printed notes
|
||||
-- are preserved by merging them into internal_notes before the column drop
|
||||
-- (both are Quill HTML — concatenation is valid markup).
|
||||
|
||||
-- Preserve data: move printed notes into internal_notes
|
||||
UPDATE `invoices`
|
||||
SET `internal_notes` = CASE
|
||||
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
|
||||
ELSE CONCAT(`internal_notes`, `notes`)
|
||||
END
|
||||
WHERE `notes` IS NOT NULL AND `notes` <> '';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoices` DROP COLUMN `notes`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `invoice_sections` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`invoice_id` INTEGER NOT NULL,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
`title` VARCHAR(500) NULL,
|
||||
`title_cz` VARCHAR(500) NULL,
|
||||
`content` TEXT NULL,
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `invoice_sections_invoice_id`(`invoice_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Suppliers get a structured address (street/city/postal_code/country) like
|
||||
-- customers; the single free-text `address` blob is dropped. Existing data is
|
||||
-- split best-effort: newlines normalized to ", ", first comma segment ->
|
||||
-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city.
|
||||
-- Unparseable one-segment addresses keep their full text in `street`.
|
||||
|
||||
-- AlterTable: add the structured columns
|
||||
ALTER TABLE `sklad_suppliers`
|
||||
ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`,
|
||||
ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`,
|
||||
ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`,
|
||||
ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`;
|
||||
|
||||
-- Preserve data: best-effort split of the legacy free-text address
|
||||
UPDATE `sklad_suppliers`
|
||||
SET
|
||||
`street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''),
|
||||
`postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'),
|
||||
`city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE(
|
||||
SUBSTRING(
|
||||
REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '),
|
||||
CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2
|
||||
),
|
||||
'[0-9]{3}[ ]?[0-9]{2}', ''))), '')
|
||||
WHERE `address` IS NOT NULL AND `address` <> '';
|
||||
|
||||
-- AlterTable: drop the legacy blob
|
||||
ALTER TABLE `sklad_suppliers` DROP COLUMN `address`;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Suppliers become a full mirror of the customers model: dedicated
|
||||
-- contact_person/email/phone/notes columns are replaced by the same
|
||||
-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}],
|
||||
-- "field_order":[]}). Existing contact data is preserved as custom fields.
|
||||
|
||||
-- AlterTable: add the custom_fields blob
|
||||
ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`;
|
||||
|
||||
-- Seed an empty container for rows that carry any contact data
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY())
|
||||
WHERE COALESCE(`contact_person`, '') <> ''
|
||||
OR COALESCE(`email`, '') <> ''
|
||||
OR COALESCE(`phone`, '') <> ''
|
||||
OR COALESCE(`notes`, '') <> '';
|
||||
|
||||
-- Preserve data: each legacy column becomes one labeled custom field
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`contact_person`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`email`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`phone`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`notes`, '') <> '';
|
||||
|
||||
-- AlterTable: drop the dedicated columns
|
||||
ALTER TABLE `sklad_suppliers`
|
||||
DROP COLUMN `contact_person`,
|
||||
DROP COLUMN `email`,
|
||||
DROP COLUMN `phone`,
|
||||
DROP COLUMN `notes`;
|
||||
@@ -193,7 +193,6 @@ model customers {
|
||||
sync_version Int? @default(0)
|
||||
invoices invoices[]
|
||||
orders orders[]
|
||||
issued_orders issued_orders[]
|
||||
projects projects[]
|
||||
quotations quotations[]
|
||||
}
|
||||
@@ -235,11 +234,11 @@ model invoices {
|
||||
issued_by String? @db.VarChar(255)
|
||||
billing_text String? @db.VarChar(500)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
notes String? @db.Text
|
||||
internal_notes String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
invoice_items invoice_items[]
|
||||
invoice_sections invoice_sections[]
|
||||
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")
|
||||
|
||||
@@ -250,6 +249,19 @@ model invoices {
|
||||
@@index([order_id], map: "order_id")
|
||||
}
|
||||
|
||||
model invoice_sections {
|
||||
id Int @id @default(autoincrement())
|
||||
invoice_id Int
|
||||
position Int? @default(0)
|
||||
title String? @db.VarChar(500)
|
||||
title_cz String? @db.VarChar(500)
|
||||
content String? @db.Text
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
|
||||
|
||||
@@index([invoice_id], map: "invoice_sections_invoice_id")
|
||||
}
|
||||
|
||||
model item_templates {
|
||||
id Int @id @default(autoincrement())
|
||||
name String? @db.VarChar(255)
|
||||
@@ -348,8 +360,6 @@ model orders {
|
||||
status String? @default("prijata") @db.VarChar(30)
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||
scope_title String? @db.VarChar(500)
|
||||
scope_description String? @db.Text
|
||||
@@ -370,29 +380,43 @@ model orders {
|
||||
model issued_orders {
|
||||
id Int @id @default(autoincrement())
|
||||
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
||||
customer_id Int?
|
||||
supplier_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
|
||||
order_text String? @db.VarChar(500)
|
||||
internal_notes String? @db.Text
|
||||
locked_by Int?
|
||||
locked_at DateTime? @db.DateTime(0)
|
||||
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")
|
||||
issued_order_sections issued_order_sections[]
|
||||
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
||||
|
||||
@@index([customer_id], map: "issued_orders_customer_id")
|
||||
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||
}
|
||||
|
||||
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
|
||||
// rendered on their own PDF page. Kept as a separate table (not shared) so the
|
||||
// FK cascade and per-document ordering stay simple.
|
||||
model issued_order_sections {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
position Int? @default(0)
|
||||
title String? @db.VarChar(500)
|
||||
title_cz String? @db.VarChar(500)
|
||||
content String? @db.Text
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
|
||||
|
||||
@@index([issued_order_id], map: "issued_order_sections_order_id")
|
||||
}
|
||||
|
||||
model issued_order_items {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
@@ -401,7 +425,6 @@ model issued_order_items {
|
||||
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")
|
||||
|
||||
@@ -494,8 +517,6 @@ model quotations {
|
||||
valid_until DateTime? @db.Date
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
order_id Int?
|
||||
status String @default("active") @db.VarChar(20)
|
||||
scope_title String? @db.VarChar(500)
|
||||
@@ -787,16 +808,17 @@ model sklad_suppliers {
|
||||
name String @db.VarChar(255)
|
||||
ico String? @db.VarChar(20)
|
||||
dic String? @db.VarChar(20)
|
||||
contact_person String? @db.VarChar(255)
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
address String? @db.Text
|
||||
notes String? @db.Text
|
||||
street String? @db.VarChar(255)
|
||||
city String? @db.VarChar(255)
|
||||
postal_code String? @db.VarChar(20)
|
||||
country String? @db.VarChar(100)
|
||||
custom_fields String? @db.LongText
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
receipts sklad_receipts[]
|
||||
issued_orders issued_orders[]
|
||||
|
||||
@@map("sklad_suppliers")
|
||||
}
|
||||
|
||||
116
src/__tests__/ares.test.ts
Normal file
116
src/__tests__/ares.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { aresLookupByIco, aresSearchByName } from "../services/ares.service";
|
||||
|
||||
// ARES is a true external — mock global fetch (the only allowed mock class).
|
||||
const SUBJECT = {
|
||||
ico: "22599851",
|
||||
obchodniJmeno: "BOHA Automation s.r.o.",
|
||||
dic: "CZ22599851",
|
||||
sidlo: {
|
||||
nazevStatu: "Česká republika",
|
||||
nazevObce: "Turnov",
|
||||
nazevCastiObce: "Turnov",
|
||||
nazevUlice: "Nádražní",
|
||||
cisloDomovni: 485,
|
||||
psc: 51101,
|
||||
textovaAdresa: "Nádražní 485, 51101 Turnov",
|
||||
},
|
||||
};
|
||||
|
||||
function mockFetchOnce(status: number, body?: unknown) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("aresLookupByIco", () => {
|
||||
it("maps a subject to the normalized prefill shape", async () => {
|
||||
mockFetchOnce(200, SUBJECT);
|
||||
const res = await aresLookupByIco("22599851");
|
||||
expect(res).toEqual({
|
||||
ico: "22599851",
|
||||
dic: "CZ22599851",
|
||||
name: "BOHA Automation s.r.o.",
|
||||
street: "Nádražní 485",
|
||||
city: "Turnov",
|
||||
postal_code: "511 01",
|
||||
country: "Česká republika",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => {
|
||||
mockFetchOnce(200, {
|
||||
...SUBJECT,
|
||||
sidlo: {
|
||||
...SUBJECT.sidlo,
|
||||
cisloOrientacni: 12,
|
||||
cisloOrientacniPismeno: "a",
|
||||
},
|
||||
});
|
||||
const res = await aresLookupByIco("22599851");
|
||||
expect("street" in res && res.street).toBe("Nádražní 485/12a");
|
||||
});
|
||||
|
||||
it("rejects a non-8-digit IČO without calling ARES", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" });
|
||||
expect(await aresLookupByIco("abcdefgh")).toEqual({
|
||||
error: "invalid_ico",
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an IČO with stray whitespace", async () => {
|
||||
const spy = mockFetchOnce(200, SUBJECT);
|
||||
const res = await aresLookupByIco(" 225 99851 ");
|
||||
expect("ico" in res && res.ico).toBe("22599851");
|
||||
expect(String(spy.mock.calls[0][0])).toContain("/22599851");
|
||||
});
|
||||
|
||||
it("maps 404 to not_found and network failure to ares_unavailable", async () => {
|
||||
mockFetchOnce(404);
|
||||
expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" });
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT"));
|
||||
expect(await aresLookupByIco("12345678")).toEqual({
|
||||
error: "ares_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aresSearchByName", () => {
|
||||
it("maps the result list and sends the name in the POST body", async () => {
|
||||
const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] });
|
||||
const res = await aresSearchByName("BOHA Automation");
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
if (Array.isArray(res)) {
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].name).toBe("BOHA Automation s.r.o.");
|
||||
expect(res[0].postal_code).toBe("511 01");
|
||||
}
|
||||
const init = spy.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
obchodniJmeno: "BOHA Automation",
|
||||
pocet: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns [] for a blank query without calling ARES", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
expect(await aresSearchByName(" ")).toEqual([]);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps upstream failure to ares_unavailable", async () => {
|
||||
mockFetchOnce(503);
|
||||
expect(await aresSearchByName("BOHA")).toEqual({
|
||||
error: "ares_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
445
src/__tests__/attendance.test.ts
Normal file
445
src/__tests__/attendance.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import attendanceRoutes from "../routes/admin/attendance";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression tests for the attendance create/edit wire format.
|
||||
//
|
||||
// The admin create/edit forms send COMBINED local datetimes
|
||||
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
|
||||
// for arrival_time / departure_time / break_start / break_end, and the
|
||||
// service parses them with `new Date(...)`. Commit 519edce validated these
|
||||
// fields with `timeString` (bare HH:MM), which rejected every create/edit
|
||||
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
|
||||
// entirely, so breaks were silently stripped on create. These tests pin the
|
||||
// combined-datetime contract at the HTTP route level.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// All fixtures live on far-future dates so they can never collide with real
|
||||
// rows in the throwaway test DB; cleanup deletes exactly this range.
|
||||
const RANGE_START = new Date("2098-01-01T00:00:00");
|
||||
const RANGE_END = new Date("2099-01-01T00:00:00");
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
|
||||
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authPost(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authPut(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupFixtureRows() {
|
||||
await prisma.attendance.deleteMany({
|
||||
where: {
|
||||
user_id: adminUserId,
|
||||
shift_date: { gte: RANGE_START, lt: RANGE_END },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtureRows();
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/attendance (combined datetimes)", () => {
|
||||
it("creates a work record with arrival+departure datetimes and persists them", async () => {
|
||||
const arrival = "2098-03-10T08:00:00";
|
||||
const departure = "2098-03-10T16:30:00";
|
||||
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-10",
|
||||
leave_type: "work",
|
||||
arrival_time: arrival,
|
||||
departure_time: departure,
|
||||
notes: "attendance_wire_test work",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec).toBeTruthy();
|
||||
expect(rec!.leave_type).toBe("work");
|
||||
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
|
||||
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
|
||||
});
|
||||
|
||||
it("persists break_start/break_end on create (previously silently stripped)", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-11",
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-11T08:00:00",
|
||||
departure_time: "2098-03-11T16:30:00",
|
||||
break_start: "2098-03-11T12:00:00",
|
||||
break_end: "2098-03-11T12:30:00",
|
||||
notes: "attendance_wire_test break",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:30:00").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a leave record with NO time fields", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-12",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test leave",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(Number(rec!.leave_hours)).toBe(8);
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
|
||||
// The AttendanceCreate page historically always sent empty strings for
|
||||
// the unfilled time fields — even for leave records — which 400'd EVERY
|
||||
// submit after 519edce. The schema must treat "" as "not set".
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-13",
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
arrival_time: "",
|
||||
departure_time: "",
|
||||
break_start: "",
|
||||
break_end: "",
|
||||
notes: "attendance_wire_test empty strings",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("sick");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
expect(rec!.break_start).toBeNull();
|
||||
expect(rec!.break_end).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/attendance (complete month, no truncation)", () => {
|
||||
it("returns every record of a month with more than 100 rows", async () => {
|
||||
// The admin month view computes the KPI cards and summary totals from
|
||||
// this single fetch, so it must cover the COMPLETE month. With the old
|
||||
// 100-row pagination cap, months with >100 records were silently
|
||||
// truncated (newest-first) and the screen totals dropped the earliest
|
||||
// days while the print stayed complete.
|
||||
const rows = [];
|
||||
for (let day = 1; day <= 30; day++) {
|
||||
for (let s = 0; s < 4; s++) {
|
||||
rows.push({
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 4, day, 12, 0, 0),
|
||||
leave_type: "work" as const,
|
||||
arrival_time: new Date(2098, 4, day, 6 + s, 0, 0),
|
||||
departure_time: new Date(2098, 4, day, 10 + s, 0, 0),
|
||||
notes: "attendance_wire_test bulk month",
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.attendance.createMany({ data: rows });
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=5&limit=2000",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(120);
|
||||
expect(body.pagination.total).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
|
||||
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
|
||||
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
|
||||
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
|
||||
// previous day) shifted the whole window a day back: the June list
|
||||
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
|
||||
// 2098-06 / 2098-07 boundary.
|
||||
const juneLast = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
|
||||
leave_type: "work",
|
||||
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
|
||||
departure_time: new Date(2098, 5, 30, 16, 0, 0),
|
||||
notes: "attendance_wire_test june-last-day",
|
||||
},
|
||||
});
|
||||
const julyFirst = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
|
||||
leave_type: "work",
|
||||
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
|
||||
departure_time: new Date(2098, 6, 1, 16, 0, 0),
|
||||
notes: "attendance_wire_test july-first-day",
|
||||
},
|
||||
});
|
||||
|
||||
const june = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=6&limit=100",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(june.statusCode).toBe(200);
|
||||
const juneIds = june.json().data.map((r: { id: number }) => r.id);
|
||||
expect(juneIds).toContain(juneLast.id);
|
||||
expect(juneIds).not.toContain(julyFirst.id);
|
||||
|
||||
const july = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=7&limit=100",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(july.statusCode).toBe(200);
|
||||
const julyIds = july.json().data.map((r: { id: number }) => r.id);
|
||||
expect(julyIds).toContain(julyFirst.id);
|
||||
expect(julyIds).not.toContain(juneLast.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
|
||||
it("rejects a second leave record on the SAME day", async () => {
|
||||
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test dup-leave-first",
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
|
||||
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
|
||||
// day — a same-day duplicate leave sailed through undetected.
|
||||
const second = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test dup-leave-second",
|
||||
});
|
||||
expect(second.statusCode).toBe(400);
|
||||
expect(second.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
|
||||
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test neighbor-day-first",
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
// Old bug (the other half): creating a record for the NEXT day queried
|
||||
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
|
||||
// with a false "záznam o nepřítomnosti již existuje".
|
||||
const nextDay = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-11",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test neighbor-day-next",
|
||||
});
|
||||
expect(nextDay.statusCode).toBe(201);
|
||||
expect(nextDay.json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
|
||||
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 14, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-14T08:00:00"),
|
||||
departure_time: new Date("2098-03-14T16:30:00"),
|
||||
notes: "attendance_wire_test update",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-14T22:00:00",
|
||||
departure_time: "2098-03-15T06:00:00",
|
||||
break_start: "2098-03-15T02:00:00",
|
||||
break_end: "2098-03-15T02:30:00",
|
||||
notes: "attendance_wire_test updated",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.arrival_time?.getTime()).toBe(
|
||||
new Date("2098-03-14T22:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.departure_time?.getTime()).toBe(
|
||||
new Date("2098-03-15T06:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:30:00").getTime(),
|
||||
);
|
||||
expect(rec!.notes).toBe("attendance_wire_test updated");
|
||||
});
|
||||
|
||||
it("clears times when null is sent (leave-type edit)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 16, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-16T08:00:00"),
|
||||
departure_time: new Date("2098-03-16T16:30:00"),
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
arrival_time: null,
|
||||
departure_time: null,
|
||||
break_start: null,
|
||||
break_end: null,
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
});
|
||||
100
src/__tests__/dashboard.test.ts
Normal file
100
src/__tests__/dashboard.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import dashboardRoutes from "../routes/admin/dashboard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
|
||||
//
|
||||
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
|
||||
// UTC date part. The dashboard used local-midnight boundaries
|
||||
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
|
||||
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
|
||||
// matched zero rows and everyone showed as absent. The boundaries must be
|
||||
// UTC-midnight instants of the LOCAL calendar day.
|
||||
//
|
||||
// Unlike the other suites this one has to use the REAL current date (the
|
||||
// route computes "today" internally), so fixtures are tracked by id and
|
||||
// deleted in afterAll.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
const createdIds: number[] = [];
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
|
||||
return a;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/dashboard — today's attendance window", () => {
|
||||
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
|
||||
const now = new Date();
|
||||
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
|
||||
// part equals the Prague calendar date) — same as punchAction does.
|
||||
const row = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
leave_type: "work",
|
||||
arrival_time: now,
|
||||
departure_time: null,
|
||||
notes: "dashboard_window_test",
|
||||
},
|
||||
});
|
||||
createdIds.push(row.id);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/dashboard",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const attendance = body.data.attendance;
|
||||
expect(attendance).toBeTruthy();
|
||||
const me = attendance.users.find(
|
||||
(u: { user_id: number }) => u.user_id === adminUserId,
|
||||
);
|
||||
expect(me).toBeTruthy();
|
||||
expect(me.status).toBe("in");
|
||||
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import {
|
||||
previewIssuedOrderNumber,
|
||||
previewInvoiceNumber,
|
||||
@@ -19,23 +22,36 @@ import {
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
invalidateOffer,
|
||||
getOffer,
|
||||
listOffers,
|
||||
} from "../services/offers.service";
|
||||
import quotationsRoutes from "../routes/admin/quotations";
|
||||
|
||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||
const issuedOrderIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
const customerIds: number[] = [];
|
||||
const linkedOrderIds: 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 } });
|
||||
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
|
||||
for (const id of linkedOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of customerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
issuedOrderIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
linkedOrderIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
customerIds.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({
|
||||
@@ -43,6 +59,14 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** createOffer + narrow the token union + track cleanup. */
|
||||
async function mkOffer(body: Record<string, unknown> = {}) {
|
||||
const res = await createOffer(body);
|
||||
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
|
||||
offerIds.push(res.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issued orders */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -51,6 +75,8 @@ 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
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.po_number).toBeNull();
|
||||
@@ -62,6 +88,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||
const expected = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
@@ -77,6 +105,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
||||
|
||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
@@ -104,6 +134,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
||||
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
||||
const a = await createIssuedOrder({});
|
||||
const b = await createIssuedOrder({});
|
||||
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
|
||||
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
|
||||
issuedOrderIds.push(a.id, b.id);
|
||||
expect(a.po_number).toBeNull();
|
||||
expect(b.po_number).toBeNull();
|
||||
@@ -112,6 +144,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
||||
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
await deleteIssuedOrder(draft.id);
|
||||
const after = (await previewIssuedOrderNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
@@ -273,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — status transition table (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer status transitions (service)", () => {
|
||||
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await updateOffer(draft.id, { status: "ordered" });
|
||||
expect("error" in res && res.error).toBe("invalid_transition");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("allows active -> invalidated via update", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
|
||||
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const rejected = await invalidateOffer(draft.id);
|
||||
expect("error" in rejected && rejected.error).toBe("invalid_transition");
|
||||
|
||||
const ordered = await mkOffer({ status: "ordered" });
|
||||
const ok = await invalidateOffer(ordered.id);
|
||||
expect("error" in ok).toBe(false);
|
||||
|
||||
// invalidated is terminal — a second invalidate is rejected too.
|
||||
const again = await invalidateOffer(ordered.id);
|
||||
expect("error" in again && again.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("getOffer exposes valid_transitions computed from the current status", async () => {
|
||||
const draft = await mkOffer({});
|
||||
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
|
||||
|
||||
const active = await mkOffer({ status: "active" });
|
||||
expect((await getOffer(active.id))?.valid_transitions).toEqual([
|
||||
"ordered",
|
||||
"invalidated",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — editable-state guard (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer editable-state guard (service)", () => {
|
||||
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
|
||||
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
|
||||
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.project_code).toBe("PŮVODNÍ");
|
||||
});
|
||||
|
||||
it("rejects a field edit on an invalidated offer", async () => {
|
||||
const offer = await mkOffer({ status: "invalidated" });
|
||||
const res = await updateOffer(offer.id, { scope_title: "X" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
});
|
||||
|
||||
it("still allows the status-only ordered -> invalidated payload", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — customer FK handling (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer customer FK handling (service)", () => {
|
||||
async function mkCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "drafts_test_zákazník" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
|
||||
const res = await createOffer({ customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("update rejects a nonexistent customer with a clean token", async () => {
|
||||
const offer = await mkOffer({});
|
||||
const res = await updateOffer(offer.id, { customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
|
||||
const customer = await mkCustomer();
|
||||
const offer = await mkOffer({ customer_id: customer.id });
|
||||
expect(offer.customer_id).toBe(customer.id);
|
||||
|
||||
const res = await updateOffer(offer.id, { customer_id: null });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.customer_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — deterministic list ordering */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("listOffers deterministic ordering", () => {
|
||||
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
|
||||
const MARK = `TIEBREAK-${Date.now()}`;
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const o = await mkOffer({ project_code: MARK });
|
||||
ids.push(o.id);
|
||||
}
|
||||
// created_at is second-precision; pin all three to the SAME timestamp so
|
||||
// only the id tiebreak can order them.
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { created_at: new Date("2026-01-15T10:00:00") },
|
||||
});
|
||||
|
||||
const base = { page: 1, limit: 10, skip: 0, search: MARK };
|
||||
const desc = await listOffers({
|
||||
...base,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
});
|
||||
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => b - a),
|
||||
);
|
||||
|
||||
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
|
||||
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — route-level hardening (Czech messages + status codes) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("offers routes — hardening (HTTP)", () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||
|
||||
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "ZMĚNA" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("PUT rejects draft -> ordered with the transition message", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { status: "ordered" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe(
|
||||
'Neplatný přechod stavu z "draft" na "ordered"',
|
||||
);
|
||||
});
|
||||
|
||||
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { items: [{ description: "x".repeat(501) }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { customer_id: 99999999 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Zákazník nenalezen");
|
||||
});
|
||||
|
||||
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: `DRAFTSLINK-${Date.now()}`,
|
||||
quotation_id: offer.id,
|
||||
},
|
||||
});
|
||||
linkedOrderIds.push(order.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().error).toBe(
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
);
|
||||
// The offer survived the rejected delete.
|
||||
expect(
|
||||
await prisma.quotations.findUnique({ where: { id: offer.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
|
||||
const draft = await mkOffer({ project_code: "PŘED" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "PO" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: {
|
||||
entity_type: "quotation",
|
||||
entity_id: draft.id,
|
||||
action: "update",
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit!.old_values).toBeTruthy();
|
||||
expect(audit!.new_values).toBeTruthy();
|
||||
expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
|
||||
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
|
||||
// Unnumbered drafts read "koncept", never "null".
|
||||
expect(audit!.description).toContain("koncept");
|
||||
expect(audit!.description).not.toContain("null");
|
||||
});
|
||||
});
|
||||
|
||||
50
src/__tests__/invoice-alerts.test.ts
Normal file
50
src/__tests__/invoice-alerts.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeAlertWindow } from "../services/invoice-alerts";
|
||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||
|
||||
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
|
||||
// a Date used in a WHERE filter to its UTC date part, so the alert window
|
||||
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
|
||||
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
|
||||
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
|
||||
// advance alert (due == today+3) could then never fire. All assertions below
|
||||
// are timezone-independent (inputs are built from local components).
|
||||
|
||||
describe("utcMidnightOfLocalDay", () => {
|
||||
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
|
||||
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
|
||||
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
|
||||
// exactly what Prisma would truncate a bare Date to.
|
||||
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
|
||||
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
|
||||
Date.UTC(2098, 6, 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns UTC midnight of the local day for a mid-day instant", () => {
|
||||
const noon = new Date(2098, 0, 15, 12, 0, 0);
|
||||
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAlertWindow", () => {
|
||||
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
|
||||
// 00:30 local — the window where the old local-midnight computation
|
||||
// produced yesterday's UTC date and the whole window slid a day early.
|
||||
const now = new Date(2098, 6, 1, 0, 30, 0);
|
||||
const w = computeAlertWindow(now);
|
||||
|
||||
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
|
||||
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
|
||||
expect(w.todayStr).toBe("2098-07-01");
|
||||
expect(w.in3daysStr).toBe("2098-07-04");
|
||||
});
|
||||
|
||||
it("rolls the +3-day bound over a month boundary", () => {
|
||||
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
|
||||
const w = computeAlertWindow(now);
|
||||
|
||||
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
|
||||
expect(w.in3daysStr).toBe("2098-02-02");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
invoiceTotalWithVat,
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
getInvoice,
|
||||
listInvoices,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../schemas/invoices.schema";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
@@ -118,3 +130,229 @@ describe("invoiceTotalWithVat", () => {
|
||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
|
||||
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
|
||||
// which STRIPS unknown keys — so a field missing from the schema is silently
|
||||
// dropped before the service ever sees it, while the client still gets a
|
||||
// success response. This block mirrors that exact flow against the real DB.
|
||||
|
||||
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("persists an edited billing_text (the schema must not strip it)", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Původní text" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
|
||||
const parsed = UpdateInvoiceSchema.parse({
|
||||
billing_text: "Fakturujeme Vám dle objednávky č. 123",
|
||||
});
|
||||
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
|
||||
const res = await updateInvoice(draft.id, parsed);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
});
|
||||
|
||||
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Text na PDF" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
|
||||
);
|
||||
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Text na PDF");
|
||||
|
||||
// Explicit null -> cleared (the strFields path maps falsy -> null).
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ billing_text: null }),
|
||||
);
|
||||
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("creates, returns and replaces CZ/EN sections in position order", async () => {
|
||||
const parsed = CreateInvoiceSchema.parse({
|
||||
billing_text: "Sekce test",
|
||||
sections: [
|
||||
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
|
||||
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
|
||||
],
|
||||
});
|
||||
const draft = await createInvoice(parsed);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
const detail = await getInvoice(draft.id);
|
||||
expect(detail?.sections?.length).toBe(2);
|
||||
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
|
||||
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
|
||||
expect(detail?.sections?.[0].position).toBe(0);
|
||||
expect(detail?.sections?.[1].position).toBe(1);
|
||||
|
||||
// Full-replace on update (same model as items).
|
||||
const upd = UpdateInvoiceSchema.parse({
|
||||
sections: [
|
||||
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
|
||||
],
|
||||
});
|
||||
const res = await updateInvoice(draft.id, upd);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Jediná");
|
||||
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
|
||||
});
|
||||
|
||||
it("sections survive an items-only update untouched (absent = keep)", async () => {
|
||||
const draft = await createInvoice(
|
||||
CreateInvoiceSchema.parse({
|
||||
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
|
||||
}),
|
||||
);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({
|
||||
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Drží");
|
||||
expect(after?.items?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("silently strips the dropped `notes` key from legacy payloads", async () => {
|
||||
// The printed-notes column was dropped (notes are internal-only now) —
|
||||
// a legacy client still sending `notes` must not 500 or write anything.
|
||||
const parsedCreate = CreateInvoiceSchema.parse({
|
||||
notes: "<p>stará poznámka</p>",
|
||||
internal_notes: "<p>interní</p>",
|
||||
});
|
||||
expect("notes" in parsedCreate).toBe(false);
|
||||
|
||||
const draft = await createInvoice(parsedCreate);
|
||||
invoiceIds.push(draft.id);
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.internal_notes).toBe("<p>interní</p>");
|
||||
|
||||
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
|
||||
expect("notes" in parsedUpdate).toBe(false);
|
||||
const res = await updateInvoice(draft.id, parsedUpdate);
|
||||
expect("error" in res).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Month filter boundaries — issue_date is @db.Date */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
|
||||
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
|
||||
// shifted the window a day back — the list/totals included the previous
|
||||
// month's last day and DROPPED invoices issued on the selected month's LAST
|
||||
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
|
||||
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
|
||||
// 2098 dates so totals can be asserted exactly without colliding with other
|
||||
// suites' rows.
|
||||
|
||||
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
|
||||
const cleanup = async () => {
|
||||
// invoice_items cascade on invoice delete.
|
||||
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
|
||||
};
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterEach(cleanup);
|
||||
|
||||
const listParams = {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
skip: 0,
|
||||
sort: "id",
|
||||
order: "asc" as const,
|
||||
search: "",
|
||||
};
|
||||
|
||||
async function createBoundaryFixtures() {
|
||||
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
|
||||
// status filter so they appear in the list/totals like any invoice.
|
||||
const lastDayJan = await createInvoice({
|
||||
status: "draft",
|
||||
issue_date: "2098-01-31",
|
||||
currency: "TST",
|
||||
vat_rate: 21,
|
||||
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
|
||||
});
|
||||
const firstDayFeb = await createInvoice({
|
||||
status: "draft",
|
||||
issue_date: "2098-02-01",
|
||||
currency: "TST",
|
||||
vat_rate: 21,
|
||||
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
|
||||
});
|
||||
return { lastDayJan, firstDayFeb };
|
||||
}
|
||||
|
||||
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
|
||||
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
|
||||
|
||||
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
|
||||
const janIds = jan.data.map((i) => i.id);
|
||||
expect(janIds).toContain(lastDayJan.id);
|
||||
expect(janIds).not.toContain(firstDayFeb.id);
|
||||
|
||||
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
|
||||
const febIds = feb.data.map((i) => i.id);
|
||||
expect(febIds).toContain(firstDayFeb.id);
|
||||
expect(febIds).not.toContain(lastDayJan.id);
|
||||
});
|
||||
|
||||
it("per-currency totals include the month's last day exactly once", async () => {
|
||||
await createBoundaryFixtures();
|
||||
|
||||
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
|
||||
const janTst = jan.totals.find((t) => t.currency === "TST");
|
||||
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
|
||||
// Feb 1 invoice (242) is NOT pulled into January.
|
||||
expect(janTst?.amount).toBe(121);
|
||||
|
||||
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
|
||||
const febTst = feb.totals.find((t) => t.currency === "TST");
|
||||
expect(febTst?.amount).toBe(242);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,6 @@ const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
@@ -146,6 +142,84 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
expect(project?.project_number).not.toBe(order?.order_number);
|
||||
expect(project?.order_id).toBe(orderId);
|
||||
});
|
||||
|
||||
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
||||
const draft = await prisma.quotations.create({
|
||||
data: { status: "draft", currency: "CZK", language: "cs" },
|
||||
});
|
||||
createdQuotationIds.push(draft.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "draft"');
|
||||
}
|
||||
// The offer is untouched — still a numberless draft.
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
expect(row!.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
||||
const invalidated = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-INVAL-${Date.now()}`,
|
||||
status: "invalidated",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(invalidated.id);
|
||||
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: invalidated.id,
|
||||
});
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "invalidated"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder reverts the source offer (stuck-ordered regression)", () => {
|
||||
it("returns the linked quotation to active with order_id cleared", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-REVERT-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: quotation.id });
|
||||
if (!("data" in res) || !res.data) failResult(res);
|
||||
const project = await prisma.projects.findFirst({
|
||||
where: { order_id: res.data.order_id },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
|
||||
const afterCreate = await prisma.quotations.findUnique({
|
||||
where: { id: quotation.id },
|
||||
});
|
||||
expect(afterCreate!.status).toBe("ordered");
|
||||
expect(afterCreate!.order_id).toBe(res.data.order_id);
|
||||
|
||||
const del = await deleteOrder(res.data.order_id);
|
||||
if ("error" in del) failResult(del);
|
||||
|
||||
// The offer must be orderable again — `ordered` with no order_id is a
|
||||
// dead end (the transition table only allows ordered -> invalidated).
|
||||
const afterDelete = await prisma.quotations.findUnique({
|
||||
where: { id: quotation.id },
|
||||
});
|
||||
expect(afterDelete!.order_id).toBeNull();
|
||||
expect(afterDelete!.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder releases the project number (release regression)", () => {
|
||||
|
||||
@@ -53,4 +53,29 @@ describe("NasDocumentManager issued-PDF round-trip", () => {
|
||||
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("re-saving the same number OVERWRITES in place — never a _1 duplicate", () => {
|
||||
const first = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v1"));
|
||||
const second = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v2"));
|
||||
|
||||
// Same canonical path both times, newest content wins.
|
||||
expect(second).toBe(first);
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
expect(fs.readdirSync(dir)).toEqual(["25P0004.pdf"]);
|
||||
expect(nas.readIssued(second!)!.data.toString()).toBe("v2");
|
||||
});
|
||||
|
||||
it("cleanIssued also sweeps stale _N duplicates left by the old racy save", () => {
|
||||
nas.saveIssuedPdf("25P0005", 2026, 6, Buffer.from("canonical"));
|
||||
// Simulate duplicates the pre-fix uniquePath save created during races.
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_1.pdf"), "stale");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_2.pdf"), "stale");
|
||||
// A DIFFERENT number must not be touched by the suffix match.
|
||||
nas.saveIssuedPdf("25P0006", 2026, 6, Buffer.from("other"));
|
||||
|
||||
nas.cleanIssued("25P0005");
|
||||
|
||||
expect(fs.readdirSync(dir).sort()).toEqual(["25P0006.pdf"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,18 +54,16 @@ function amountFor(
|
||||
}
|
||||
|
||||
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
|
||||
it("sums offer NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
|
||||
// not tax documents, no VAT anywhere).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
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 }],
|
||||
});
|
||||
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a status filter narrows the result", async () => {
|
||||
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
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 }],
|
||||
});
|
||||
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
|
||||
await mk("draft");
|
||||
await mk("invalidated");
|
||||
|
||||
// Marker only: all three count -> 3 x 1210 = 3630.
|
||||
// Marker only: all three count -> 3 x 1000 = 3000 (net).
|
||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3630);
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3000);
|
||||
|
||||
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
|
||||
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
|
||||
const onlyDraft = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "draft",
|
||||
});
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Invalidated alone -> 1210.
|
||||
// Invalidated alone -> 1000.
|
||||
const onlyInvalid = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "invalidated",
|
||||
});
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
|
||||
// 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.
|
||||
// endpoints sum order NET total per currency across the WHOLE filtered set
|
||||
// (orders are not tax documents — no VAT anywhere), 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).
|
||||
@@ -46,19 +47,17 @@ function amountFor(
|
||||
}
|
||||
|
||||
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
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
||||
// (enrichOrder math — orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
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 }],
|
||||
});
|
||||
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
@@ -122,49 +119,45 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
||||
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
||||
const onlyPrijata = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "prijata",
|
||||
});
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1210.
|
||||
// Next month sees only the one 'prijata' there -> 1000.
|
||||
const nextMonth = await getOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. NET only
|
||||
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
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 },
|
||||
],
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||
createdIssuedIds.push(o.id);
|
||||
return o.id;
|
||||
};
|
||||
@@ -177,69 +170,63 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
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.
|
||||
// Target month: one draft, one already-sent (both 1000 net). 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 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
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 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in sent)
|
||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||
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 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in next)
|
||||
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||
createdIssuedIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
||||
const onlySent = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "sent",
|
||||
});
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one order there -> 1210.
|
||||
// Next month sees only the one order there -> 1000.
|
||||
const nextStats = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { createOrder, listOrders } from "../services/orders.service";
|
||||
import { config } from "../config/env";
|
||||
import ordersRoutes from "../routes/admin/orders";
|
||||
import {
|
||||
createOrder,
|
||||
listOrders,
|
||||
getOrder,
|
||||
getOrderAttachment,
|
||||
} from "../services/orders.service";
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
@@ -19,8 +30,6 @@ const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
@@ -80,6 +89,166 @@ describe("listOrders search filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("order attachment payload hygiene", () => {
|
||||
// The PO attachment blob must be served ONLY by GET /:id/attachment —
|
||||
// list/detail payloads carry attachment_name as the existence signal.
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
|
||||
const TEST_ADMIN = "orders_list_test_admin";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
|
||||
let adminToken: string;
|
||||
let adminUserId: number;
|
||||
|
||||
async function buildOrdersApp() {
|
||||
const fastify = Fastify({ logger: false });
|
||||
await fastify.register(cookie);
|
||||
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
// multipart is registered inside ordersRoutes itself
|
||||
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
|
||||
return fastify;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildOrdersApp();
|
||||
|
||||
// Clean up any leftover test user from a previous failed run
|
||||
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
|
||||
|
||||
const adminRole = await prisma.roles.findFirst({
|
||||
where: { name: "admin" },
|
||||
});
|
||||
if (!adminRole)
|
||||
throw new Error("Admin role not found — seed the database first");
|
||||
|
||||
const adminUser = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_ADMIN,
|
||||
email: `${TEST_ADMIN}@test.local`,
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Orders",
|
||||
last_name: "ListTest",
|
||||
is_active: true,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
adminUserId = adminUser.id;
|
||||
adminToken = jwt.sign(
|
||||
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.users.deleteMany({ where: { id: adminUserId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** One order WITH a stored PO attachment, one WITHOUT. */
|
||||
async function makeOrderPair() {
|
||||
const customer = await makeCustomer();
|
||||
const withRes = await createOrder(
|
||||
{ ...baseOrder, customer_id: customer.id, create_project: false },
|
||||
PDF_BYTES,
|
||||
"po-test.pdf",
|
||||
);
|
||||
if (!("id" in withRes)) failResult(withRes);
|
||||
createdOrderIds.push(withRes.id!);
|
||||
|
||||
const withoutRes = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in withoutRes)) failResult(withoutRes);
|
||||
createdOrderIds.push(withoutRes.id!);
|
||||
|
||||
return { withId: withRes.id!, withoutId: withoutRes.id! };
|
||||
}
|
||||
|
||||
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const list = await listOrders(baseListParams);
|
||||
const withRow = list.data.find((o) => o.id === withId);
|
||||
const withoutRow = list.data.find((o) => o.id === withoutId);
|
||||
expect(withRow).toBeTruthy();
|
||||
expect(withoutRow).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withRow!).toBe(false);
|
||||
expect("attachment_data" in withoutRow!).toBe(false);
|
||||
expect(withRow!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutRow!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const withDetail = await getOrder(withId);
|
||||
const withoutDetail = await getOrder(withoutId);
|
||||
expect(withDetail).toBeTruthy();
|
||||
expect(withoutDetail).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withDetail!).toBe(false);
|
||||
expect("attachment_data" in withoutDetail!).toBe(false);
|
||||
expect(withDetail!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutDetail!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
const headers = { authorization: `Bearer ${adminToken}` };
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
|
||||
headers,
|
||||
});
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const listJson = JSON.parse(listRes.body);
|
||||
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
|
||||
expect(listRes.body).not.toContain("attachment_data");
|
||||
|
||||
const detailRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}`,
|
||||
headers,
|
||||
});
|
||||
expect(detailRes.statusCode).toBe(200);
|
||||
expect(detailRes.body).not.toContain("attachment_data");
|
||||
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
|
||||
|
||||
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
|
||||
const attRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(attRes.statusCode).toBe(200);
|
||||
expect(attRes.headers["content-type"]).toContain("application/pdf");
|
||||
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
const noAttRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withoutId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(noAttRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const att = await getOrderAttachment(withId);
|
||||
expect(att).toBeTruthy();
|
||||
expect(att!.filename).toBe("po-test.pdf");
|
||||
expect(att!.data.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
expect(await getOrderAttachment(withoutId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listOrders month filter", () => {
|
||||
it("returns only orders whose created_at is in the given month", async () => {
|
||||
const customer = await makeCustomer();
|
||||
|
||||
247
src/__tests__/pdf-no-vat.test.ts
Normal file
247
src/__tests__/pdf-no-vat.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
|
||||
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||||
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||||
// is covered in issued-orders.test.ts.)
|
||||
|
||||
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||||
// total label alone carries the information. Pin its absence too.
|
||||
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||||
const EN_NOTE = "Prices are exclusive of VAT";
|
||||
|
||||
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||
const order = {
|
||||
order_number: "26730042",
|
||||
customer_order_number: "PO-XYZ",
|
||||
created_at: new Date("2026-06-09T12:00:00"),
|
||||
currency: "CZK",
|
||||
notes: null,
|
||||
customers: null,
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
description: "Materiál",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 100,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
];
|
||||
|
||||
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain(">DPH<");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||||
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
});
|
||||
|
||||
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||||
expect(html).not.toContain("VAT%");
|
||||
expect(html).toContain("Total excl. VAT");
|
||||
expect(html).not.toContain(EN_NOTE);
|
||||
});
|
||||
|
||||
it("excludes not-included lines from the NET total", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
...items,
|
||||
{
|
||||
description: "Mimo cenu",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 999,
|
||||
is_included_in_total: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||
expect(html).toContain("200,00 CZK");
|
||||
});
|
||||
|
||||
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
{
|
||||
description: "Velká položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||||
expect(html).toContain("2 000,00 CZK");
|
||||
expect(html).not.toContain("1 199,00 CZK");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offer PDF route (returns the HTML for the print view) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
const createdQuotationIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||||
// Direct prisma insert (explicit number → no sequence consumed).
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
expect(html).not.toContain("Celkem k úhradě");
|
||||
});
|
||||
|
||||
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Půldenní sazba",
|
||||
quantity: 1.5,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||||
// decimals elsewhere (covered by the previous test's qty 2).
|
||||
expect(html).toContain("1,50 / ks");
|
||||
expect(html).not.toContain(">2 / ks<");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||||
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||||
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||||
const invoice = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
items: [
|
||||
{
|
||||
description: "PDF-SHARED-MARKER",
|
||||
quantity: 2,
|
||||
unit_price: 1000,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
createdInvoiceIds.push(invoice.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
|
||||
expect(html).toContain("PDF-SHARED-MARKER");
|
||||
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||||
expect(html).toContain("2 000,00");
|
||||
expect(html).toContain("Celkem k úhradě");
|
||||
});
|
||||
});
|
||||
@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
it("rejects NaN string in top-level vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "bad",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: 21,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("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,
|
||||
@@ -268,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
||||
).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("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
|
||||
// email/phone/contact_person/notes/address columns were replaced by the
|
||||
// customers-model custom_fields blob (2026-06); legacy clients still
|
||||
// sending them must not 400 — z.object strips unknown keys.
|
||||
const parsed = CreateSupplierSchema.safeParse({
|
||||
name: "Dodavatel",
|
||||
email: "x@y.cz",
|
||||
phone: "123",
|
||||
contact_person: "Jan",
|
||||
notes: "n",
|
||||
address: "Ulice 1",
|
||||
custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect("email" in parsed.data).toBe(false);
|
||||
expect("address" in parsed.data).toBe(false);
|
||||
expect(parsed.data.custom_fields).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||
@@ -300,8 +279,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
||||
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,
|
||||
);
|
||||
expect(
|
||||
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
239
src/__tests__/totp-backup.test.ts
Normal file
239
src/__tests__/totp-backup.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as OTPAuthLib from "otpauth";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { encrypt } from "../utils/encryption";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "totp_backup_test_";
|
||||
const TEST_USERNAME = `${N}user`;
|
||||
|
||||
// Plaintext backup codes seeded for the fixture user (the enable route stores
|
||||
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
|
||||
const BACKUP_CODE_1 = "AAAA1111";
|
||||
const BACKUP_CODE_2 = "BBBB2222";
|
||||
|
||||
let testUserId: number;
|
||||
|
||||
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
|
||||
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
|
||||
const cookies = res.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
|
||||
| string
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
|
||||
async function issueLoginToken(userId: number): Promise<string> {
|
||||
const raw = crypto.randomBytes(32).toString("hex");
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
await prisma.totp_login_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hash,
|
||||
expires_at: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
// /totp/backup-verify carries its own per-IP rate limit
|
||||
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
|
||||
// more attempts than that — give each request a distinct source IP.
|
||||
let ipCounter = 0;
|
||||
function postBackupVerify(payload: Record<string, unknown>) {
|
||||
ipCounter += 1;
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/totp/backup-verify",
|
||||
payload,
|
||||
remoteAddress: `10.99.0.${ipCounter}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureUser() {
|
||||
const user = await prisma.users.findUniqueOrThrow({
|
||||
where: { id: testUserId },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run.
|
||||
const leftovers = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (leftovers.length) {
|
||||
const ids = leftovers.map((u) => u.id);
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: { in: ids } },
|
||||
});
|
||||
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");
|
||||
|
||||
const hashedCodes = [
|
||||
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
|
||||
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
|
||||
];
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_USERNAME,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: await bcrypt.hash(
|
||||
"irrelevant",
|
||||
config.security.bcryptCost,
|
||||
),
|
||||
first_name: "TotpBackup",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: true,
|
||||
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
|
||||
totp_backup_codes: JSON.stringify(hashedCodes),
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refresh_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.totp_login_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/totp/backup-verify", () => {
|
||||
it("returns 400 for missing fields", async () => {
|
||||
const res = await postBackupVerify({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid login token", async () => {
|
||||
const res = await postBackupVerify({
|
||||
login_token: "not-a-real-token",
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong backup code without consuming the stored codes", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: "ZZZZ9999",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
// Both backup codes must still be stored — nothing consumed.
|
||||
const user = await fixtureUser();
|
||||
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
|
||||
|
||||
// Reset the failed-attempt counter so this test can't lock the fixture
|
||||
// user for later tests (max_login_attempts comes from system settings).
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
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);
|
||||
|
||||
// Same login completion as the TOTP path: httpOnly refresh cookie set.
|
||||
const cookie = extractCookie(res, "refresh_token");
|
||||
expect(cookie).toBeTruthy();
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(/HttpOnly/i);
|
||||
expect(raw).toMatch(/SameSite=Strict/i);
|
||||
|
||||
// The used backup code must be removed (single-use).
|
||||
const user = await fixtureUser();
|
||||
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
|
||||
expect(remaining).toHaveLength(1);
|
||||
|
||||
// The login token must be consumed: replaying it (even with the second,
|
||||
// still-valid backup code) is rejected.
|
||||
const replay = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects reuse of an already-consumed backup code", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
remember_me: true,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(
|
||||
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
|
||||
);
|
||||
|
||||
const refreshRow = await prisma.refresh_tokens.findFirst({
|
||||
where: { user_id: testUserId },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(refreshRow?.remember_me).toBe(true);
|
||||
});
|
||||
});
|
||||
541
src/__tests__/trips.test.ts
Normal file
541
src/__tests__/trips.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import tripsRoutes from "../routes/admin/trips";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route-level tests for the trips domain:
|
||||
//
|
||||
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
|
||||
// (UpdateTripSchema previously had no vehicle_id, so the edit modal's
|
||||
// vehicle change was silently dropped while the UI reported success) and
|
||||
// recomputes actual_km on BOTH vehicles.
|
||||
// 2. GET /trips/stats aggregates count/total/business/private km over the
|
||||
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
||||
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
||||
// non-managers to their own trips exactly like the list does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
||||
const N = "trips_test_";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
let scopeUserId: number;
|
||||
let scopeRoleId: number;
|
||||
let scopeToken: string;
|
||||
let vehicleAId: number;
|
||||
let vehicleBId: number;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
|
||||
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authGet(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function authPut(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
function tripRow(params: {
|
||||
userId: number;
|
||||
vehicleId: number;
|
||||
date: string;
|
||||
startKm: number;
|
||||
dist: number;
|
||||
isBusiness: boolean;
|
||||
}) {
|
||||
return {
|
||||
user_id: params.userId,
|
||||
vehicle_id: params.vehicleId,
|
||||
trip_date: new Date(params.date),
|
||||
start_km: params.startKm,
|
||||
end_km: params.startKm + params.dist,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
is_business: params.isBusiness,
|
||||
notes: `${N}fixture`,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtureTrips() {
|
||||
await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Dedicated non-admin user with trips.record + trips.history (but NOT
|
||||
// trips.manage) for the own-trips scoping tests.
|
||||
const stamp = Date.now().toString(36);
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: `${N}role_${stamp}`,
|
||||
display_name: "Trips Scope Test",
|
||||
},
|
||||
});
|
||||
scopeRoleId = role.id;
|
||||
const perms = await prisma.permissions.findMany({
|
||||
where: { name: { in: ["trips.record", "trips.history"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (perms.length !== 2)
|
||||
throw new Error(
|
||||
"Test setup: trips.record/trips.history permissions missing",
|
||||
);
|
||||
await prisma.role_permissions.createMany({
|
||||
data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
|
||||
});
|
||||
const scopeUser = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user_${stamp}`,
|
||||
first_name: "Trips",
|
||||
last_name: "Scope",
|
||||
email: `${N}${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
scopeUserId = scopeUser.id;
|
||||
if (scopeUserId === adminUserId)
|
||||
throw new Error("scope user must be distinct from admin");
|
||||
scopeToken = generateToken({
|
||||
id: scopeUser.id,
|
||||
username: scopeUser.username,
|
||||
roleName: role.name,
|
||||
});
|
||||
|
||||
// Two dedicated vehicles (spz is unique, VarChar(20)).
|
||||
const vehicleA = await prisma.vehicles.create({
|
||||
data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
|
||||
});
|
||||
const vehicleB = await prisma.vehicles.create({
|
||||
data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
|
||||
});
|
||||
vehicleAId = vehicleA.id;
|
||||
vehicleBId = vehicleB.id;
|
||||
|
||||
await cleanupFixtureTrips();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtureTrips();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtureTrips();
|
||||
await prisma.vehicles
|
||||
.deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
|
||||
.catch(() => {});
|
||||
if (scopeUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: scopeUserId } })
|
||||
.catch(() => {});
|
||||
if (scopeRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: scopeRoleId } })
|
||||
.catch(() => {});
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/trips/:id — vehicle_id", () => {
|
||||
it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-04-10",
|
||||
startKm: 1000,
|
||||
dist: 500,
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// The edit form sends the id as a string (Select value) — intIdFromForm
|
||||
// must coerce it.
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
vehicle_id: String(vehicleBId),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
|
||||
expect(updated!.vehicle_id).toBe(vehicleBId);
|
||||
|
||||
// New vehicle picks up the trip's odometer reading…
|
||||
const vehB = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleBId },
|
||||
});
|
||||
expect(vehB!.actual_km).toBe(1500);
|
||||
// …and the old vehicle falls back to initial_km (no trips left on it).
|
||||
const vehA = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleAId },
|
||||
});
|
||||
expect(vehA!.actual_km).toBe(500);
|
||||
});
|
||||
|
||||
it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
|
||||
// Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
|
||||
// of the vehicle's trips, not just the edited one.
|
||||
const trip1 = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-09-10",
|
||||
startKm: 1000,
|
||||
dist: 500, // end_km 1500
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
const trip2 = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-09-12",
|
||||
startKm: 1800,
|
||||
dist: 200, // end_km 2000
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
|
||||
const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
|
||||
end_km: 2200,
|
||||
});
|
||||
expect(raise.statusCode).toBe(200);
|
||||
let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
||||
expect(vehA!.actual_km).toBe(2200);
|
||||
|
||||
// …while editing the non-MAX trip recomputes but keeps MAX(end_km).
|
||||
const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
|
||||
end_km: 1600,
|
||||
});
|
||||
expect(lower.statusCode).toBe(200);
|
||||
const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
|
||||
expect(updated!.end_km).toBe(1600);
|
||||
expect(updated!.vehicle_id).toBe(vehicleAId);
|
||||
vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
||||
expect(vehA!.actual_km).toBe(2200);
|
||||
});
|
||||
|
||||
it("writes an audit row with oldValues/newValues on update", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-10-10",
|
||||
startKm: 100,
|
||||
dist: 50, // end_km 150
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
end_km: 180,
|
||||
route_to: "Ostrava",
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: { action: "update", entity_type: "trip", entity_id: trip.id },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit!.user_id).toBe(adminUserId);
|
||||
|
||||
// oldValues = the full pre-update row, newValues = the changed fields.
|
||||
expect(audit!.old_values).toBeTruthy();
|
||||
expect(audit!.new_values).toBeTruthy();
|
||||
const oldValues = JSON.parse(audit!.old_values!);
|
||||
const newValues = JSON.parse(audit!.new_values!);
|
||||
expect(oldValues.end_km).toBe(150);
|
||||
expect(oldValues.route_to).toBe("Brno");
|
||||
expect(oldValues.vehicle_id).toBe(vehicleAId);
|
||||
expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
|
||||
});
|
||||
|
||||
it("rejects an invalid vehicle_id with 400", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-04-11",
|
||||
startKm: 100,
|
||||
dist: 50,
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
vehicle_id: "abc",
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
|
||||
expect(unchanged!.vehicle_id).toBe(vehicleAId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/trips/stats", () => {
|
||||
it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
|
||||
// 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
|
||||
const rows = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
rows.push(
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-05-10",
|
||||
startKm: 1000 + i * 10,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-05-15",
|
||||
startKm: 2000 + i * 7,
|
||||
dist: 7,
|
||||
isBusiness: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await prisma.trips.createMany({ data: rows });
|
||||
|
||||
// The list truncates to the default 25-row page but reports the real total…
|
||||
const listRes = await authGet(
|
||||
"/api/admin/trips?month=5&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const listBody = listRes.json();
|
||||
expect(listBody.data).toHaveLength(25);
|
||||
expect(listBody.pagination.total).toBe(30);
|
||||
expect(listBody.pagination.total_pages).toBe(2);
|
||||
|
||||
// …while the stats endpoint covers all 30 rows.
|
||||
const res = await authGet(
|
||||
"/api/admin/trips/stats?month=5&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual({
|
||||
count: 30,
|
||||
total_km: 20 * 10 + 10 * 7,
|
||||
business_km: 200,
|
||||
private_km: 70,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the month filter in both wire formats", async () => {
|
||||
await prisma.trips.createMany({
|
||||
data: [
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-06-10",
|
||||
startKm: 100,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-06-12",
|
||||
startKm: 110,
|
||||
dist: 20,
|
||||
isBusiness: false,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-07-05",
|
||||
startKm: 130,
|
||||
dist: 40,
|
||||
isBusiness: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Combined "YYYY-MM" format (TripsHistory)
|
||||
const june = await authGet(
|
||||
"/api/admin/trips/stats?month=2098-06",
|
||||
adminToken,
|
||||
);
|
||||
expect(june.statusCode).toBe(200);
|
||||
expect(june.json().data).toEqual({
|
||||
count: 2,
|
||||
total_km: 30,
|
||||
business_km: 10,
|
||||
private_km: 20,
|
||||
});
|
||||
|
||||
// Split month+year format (TripsAdmin)
|
||||
const july = await authGet(
|
||||
"/api/admin/trips/stats?month=7&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(july.statusCode).toBe(200);
|
||||
expect(july.json().data).toEqual({
|
||||
count: 1,
|
||||
total_km: 40,
|
||||
business_km: 40,
|
||||
private_km: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes non-managers to their own trips (user_id param ignored)", async () => {
|
||||
await prisma.trips.createMany({
|
||||
data: [
|
||||
// 2 trips for the scope user, 3 for the admin — same month.
|
||||
tripRow({
|
||||
userId: scopeUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-08-05",
|
||||
startKm: 100,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: scopeUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-08-06",
|
||||
startKm: 110,
|
||||
dist: 15,
|
||||
isBusiness: false,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-07",
|
||||
startKm: 200,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-08",
|
||||
startKm: 300,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-09",
|
||||
startKm: 400,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Non-manager: only own trips, even when explicitly requesting another user.
|
||||
const own = await authGet(
|
||||
"/api/admin/trips/stats?month=8&year=2098",
|
||||
scopeToken,
|
||||
);
|
||||
expect(own.statusCode).toBe(200);
|
||||
expect(own.json().data).toEqual({
|
||||
count: 2,
|
||||
total_km: 25,
|
||||
business_km: 10,
|
||||
private_km: 15,
|
||||
});
|
||||
|
||||
const spoofed = await authGet(
|
||||
`/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
|
||||
scopeToken,
|
||||
);
|
||||
expect(spoofed.statusCode).toBe(200);
|
||||
expect(spoofed.json().data.count).toBe(2);
|
||||
|
||||
// The list applies the exact same scoping.
|
||||
const list = await authGet(
|
||||
"/api/admin/trips?month=8&year=2098",
|
||||
scopeToken,
|
||||
);
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().data).toHaveLength(2);
|
||||
|
||||
// Manager (admin) sees everything in the month.
|
||||
const all = await authGet(
|
||||
"/api/admin/trips/stats?month=8&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(all.statusCode).toBe(200);
|
||||
expect(all.json().data.count).toBe(5);
|
||||
expect(all.json().data.total_km).toBe(325);
|
||||
|
||||
// Manager can filter to a single user.
|
||||
const filtered = await authGet(
|
||||
`/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(filtered.statusCode).toBe(200);
|
||||
expect(filtered.json().data.count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
import { nasInvoicesManager } from "../services/nas-financials-manager";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -879,6 +889,177 @@ describe("Receipt flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5b. Receipt attachment download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Receipt attachment download", () => {
|
||||
let receiptId: number;
|
||||
let otherReceiptId: number;
|
||||
let attachmentId: number;
|
||||
let diacriticAttachmentId: number;
|
||||
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
|
||||
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
|
||||
const diacriticFileName = "příloha č. 1.pdf";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Item + two receipts (attachment belongs to the first one only)
|
||||
const itemRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/items",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `${N}item_att_download`, unit: "ks" },
|
||||
});
|
||||
const itemId = itemRes.json().data.id;
|
||||
|
||||
const receiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_a`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
receiptId = receiptRes.json().data.id;
|
||||
|
||||
const otherReceiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_b`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
otherReceiptId = otherReceiptRes.json().data.id;
|
||||
|
||||
// Attachment row created directly — the upload endpoint needs a writable
|
||||
// NAS, which is not available in the test environment. The NAS read is
|
||||
// stubbed per-test on the singleton (same temp-dir/stub seam as the
|
||||
// nas-document-manager tests); the DB rows and route logic stay real.
|
||||
const attachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: "wh_test_priloha.pdf",
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: filePath,
|
||||
},
|
||||
});
|
||||
attachmentId = attachment.id;
|
||||
|
||||
// Diacritic filename — the norm for a Czech company. Node throws on
|
||||
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
|
||||
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: diacriticFileName,
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
|
||||
},
|
||||
});
|
||||
diacriticAttachmentId = diacriticAttachment.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downloads an existing attachment with correct content-type and bytes", async () => {
|
||||
const readSpy = vi
|
||||
.spyOn(nasInvoicesManager, "readReceived")
|
||||
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("application/pdf");
|
||||
expect(res.headers["content-disposition"]).toBe(
|
||||
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
expect(readSpy).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
|
||||
data: fileBytes,
|
||||
fileName: diacriticFileName,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const disposition = res.headers["content-disposition"];
|
||||
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
|
||||
expect(disposition).toContain(
|
||||
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
|
||||
);
|
||||
// … alongside an ASCII-only fallback, so the header never carries
|
||||
// chars > U+00FF (which Node's setHeader rejects → 500).
|
||||
expect(disposition).toBe(
|
||||
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when the attachment belongs to a different receipt", async () => {
|
||||
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
// Ownership is rejected before any NAS access
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for a nonexistent attachment id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when the file is missing on the NAS", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects download without warehouse.view permission (403)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(noPermToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Issue flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
153
src/admin/components/AresLookup.tsx
Normal file
153
src/admin/components/AresLookup.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useRef } from "react";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
/** Normalized company record returned by the /api/admin/ares proxy. */
|
||||
export interface AresCompany {
|
||||
ico: string;
|
||||
dic: string | null;
|
||||
name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface AresAdornmentProps {
|
||||
/** "ico" looks the query up directly; "name" searches and offers a picker. */
|
||||
mode: "ico" | "name";
|
||||
/** Current value of the host field (IČO or name fragment). */
|
||||
query: string;
|
||||
/** Called with the chosen company — the modal prefills its form from it. */
|
||||
onFill: (company: AresCompany) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "ARES" end-adornment button for the Název/IČO fields of the customer and
|
||||
* supplier modals. IČO mode fetches the subject directly; name mode searches
|
||||
* ARES and shows a result picker (filling straight away on a single hit).
|
||||
* Pass via the TextField's `InputProps.endAdornment`.
|
||||
*/
|
||||
export default function AresAdornment({
|
||||
mode,
|
||||
query,
|
||||
onFill,
|
||||
}: AresAdornmentProps) {
|
||||
const alert = useAlert();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<AresCompany[]>([]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const enabled =
|
||||
mode === "ico"
|
||||
? /^\d{8}$/.test(trimmed.replace(/\s+/g, ""))
|
||||
: trimmed.length >= 2;
|
||||
const tooltip =
|
||||
mode === "ico"
|
||||
? enabled
|
||||
? "Načíst údaje z ARES podle IČO"
|
||||
: "Vyplňte 8místné IČO"
|
||||
: enabled
|
||||
? "Vyhledat firmu v ARES podle názvu"
|
||||
: "Vyplňte alespoň 2 znaky názvu";
|
||||
|
||||
const lookup = async () => {
|
||||
if (!enabled || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "ico") {
|
||||
const company = await jsonQuery<AresCompany>(
|
||||
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
|
||||
);
|
||||
onFill(company);
|
||||
} else {
|
||||
const found = await jsonQuery<AresCompany[]>(
|
||||
`/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`,
|
||||
);
|
||||
if (found.length === 0) {
|
||||
alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena");
|
||||
} else if (found.length === 1) {
|
||||
onFill(found[0]);
|
||||
} else {
|
||||
setResults(found);
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert.error(
|
||||
e instanceof Error ? e.message : "Registr ARES je nedostupný",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
size="small"
|
||||
onClick={lookup}
|
||||
disabled={!enabled || loading}
|
||||
aria-label="Načíst z ARES"
|
||||
edge="end"
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
color: enabled ? "primary.main" : "text.disabled",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
ARES
|
||||
</Typography>
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorRef.current}
|
||||
open={menuOpen}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
>
|
||||
{results.map((c) => (
|
||||
<MenuItem
|
||||
key={c.ico}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onFill(c);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{c.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
IČO {c.ico}
|
||||
{c.city ? ` · ${c.city}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</InputAdornment>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ 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
|
||||
// Editable line-item. quantity/unit_price 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 {
|
||||
@@ -17,7 +17,6 @@ interface ConfirmationItem {
|
||||
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
|
||||
@@ -28,21 +27,14 @@ interface GeneratedItem {
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
interface OrderConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
items?: GeneratedItem[],
|
||||
) => Promise<void>;
|
||||
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
|
||||
initialItems: ConfirmationItem[];
|
||||
orderNumber: string;
|
||||
defaultVatRate: number;
|
||||
applyVat: boolean;
|
||||
}
|
||||
|
||||
export default function OrderConfirmationModal({
|
||||
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
|
||||
onGenerate,
|
||||
initialItems,
|
||||
orderNumber,
|
||||
defaultVatRate,
|
||||
applyVat,
|
||||
}: OrderConfirmationModalProps) {
|
||||
const alert = useAlert();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||
const [lang, setLang] = useState<string>("cs");
|
||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
|
||||
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.
|
||||
// initialItems 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);
|
||||
await onGenerate(lang, undefined);
|
||||
// Only close on success — a generation error must keep the modal open.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
|
||||
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);
|
||||
await onGenerate(lang, coercedItems);
|
||||
// Only close on success — on error keep the user's edited items intact.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
vat_rate: defaultVatRate,
|
||||
},
|
||||
]);
|
||||
}, [defaultVatRate]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={applyVatState ? "contained" : "outlined"}
|
||||
color={applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(true)}
|
||||
>
|
||||
S DPH
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={!applyVatState ? "contained" : "outlined"}
|
||||
color={!applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(false)}
|
||||
>
|
||||
Bez DPH
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="Obsah potvrzení">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate ?? ""}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", e.target.value)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import QRCode from "qrcode";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -134,6 +135,28 @@ export default function DashProfile({
|
||||
// locks <html> scroll like the kit dialogs.
|
||||
useDialogScrollLock(show2FASetup);
|
||||
|
||||
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
|
||||
// URI to an external QR service: the production CSP (img-src) blocks it and
|
||||
// it would leak the TOTP secret to a third party. A data: URL is allowed by
|
||||
// the CSP. Derived via useQuery (not an effect) per repo conventions.
|
||||
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
||||
queryKey: ["totp", "qr", totpQrUri],
|
||||
enabled: !!totpQrUri,
|
||||
staleTime: Infinity,
|
||||
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
||||
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
|
||||
} catch (err) {
|
||||
console.error("DashProfile: generování QR kódu selhalo", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState<ProfileFormData>({
|
||||
username: "",
|
||||
@@ -549,11 +572,11 @@ export default function DashProfile({
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||
Authy, Microsoft Authenticator apod.)
|
||||
</Typography>
|
||||
{totpQrUri && (
|
||||
{totpQrUri && totpQrDataUrl && (
|
||||
<Box sx={{ textAlign: "center", mb: 2 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
src={totpQrDataUrl}
|
||||
alt="TOTP QR Code"
|
||||
sx={{
|
||||
width: 200,
|
||||
@@ -561,10 +584,23 @@ export default function DashProfile({
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
// The QR must stay scannable in dark mode — keep it on a
|
||||
// white tile instead of inheriting the dark paper bg.
|
||||
bgcolor: "common.white",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{totpQrUri && totpQrFailed && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="warning.main"
|
||||
sx={{ mb: 2, textAlign: "center" }}
|
||||
>
|
||||
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
|
||||
aplikace ručně.
|
||||
</Typography>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
|
||||
667
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
667
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
restrictToVerticalAxis,
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button, Card, TextField } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
/**
|
||||
* One sortable line item of a document (offer / issued order). The numeric
|
||||
* fields are held as the raw typed string while editing so the input can be
|
||||
* cleared (empty renders fine in a type="number" input); coerce via
|
||||
* `Number(x) || 0` only where used (live totals + save payload).
|
||||
*/
|
||||
export interface DocumentItem {
|
||||
_key: string;
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Offers only — undefined (issued orders) counts as included. */
|
||||
is_included_in_total?: boolean;
|
||||
}
|
||||
|
||||
let documentItemKeyCounter = 0;
|
||||
|
||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||
export const nextDocumentItemKey = (): string =>
|
||||
`item-${++documentItemKeyCounter}`;
|
||||
|
||||
export const emptyDocumentItem = (): DocumentItem => ({
|
||||
_key: nextDocumentItemKey(),
|
||||
description: "",
|
||||
item_description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
/** NET total over the included items (documents here carry no VAT). */
|
||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||
items.reduce(
|
||||
(sum, it) =>
|
||||
it.is_included_in_total === false
|
||||
? sum
|
||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const DragIcon = (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="5" r="1.5" />
|
||||
<circle cx="15" cy="5" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" />
|
||||
<circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="19" r="1.5" />
|
||||
<circle cx="15" cy="19" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
);
|
||||
|
||||
function SortableItemRow({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
readOnly,
|
||||
canDelete,
|
||||
showIncludedInTotal,
|
||||
itemDescriptionMaxLength,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DocumentItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
canDelete: boolean;
|
||||
showIncludedInTotal: boolean;
|
||||
itemDescriptionMaxLength: number;
|
||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item._key, disabled: readOnly });
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
background: isDragging ? "var(--mui-palette-action-hover)" : undefined,
|
||||
position: "relative" as const,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.25,
|
||||
bgcolor: "background.paper",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
color: "text.secondary",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {index + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate("description", e.target.value)}
|
||||
placeholder="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Podrobný popis"
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jedn. cena"
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: showIncludedInTotal ? "space-between" : "flex-end",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
pt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{showIncludedInTotal && (
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
cursor: readOnly ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) =>
|
||||
onUpdate("is_included_in_total", e.target.checked)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Typography variant="body2">V ceně</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Celkem
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} sx={style}>
|
||||
{/* Stable column layout: every cell renders in read-only mode too —
|
||||
only the controls inside are hidden. */}
|
||||
<TableCell sx={{ width: "2rem", px: 0.5 }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
color: "text.secondary",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="center"
|
||||
sx={{ color: "text.secondary", fontWeight: 500 }}
|
||||
>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: "top" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate("description", e.target.value)}
|
||||
placeholder="Popis položky…"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
sx={{ "& input": { fontWeight: 500 } }}
|
||||
/>
|
||||
<TextField
|
||||
value={item.item_description}
|
||||
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
opacity: 0.8,
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity ?? ""}
|
||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||
placeholder="ks"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||
sx={{ "& input": { textAlign: "center" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price ?? ""}
|
||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
checked={item.is_included_in_total !== false}
|
||||
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
}}
|
||||
>
|
||||
{formatCurrency(lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }}>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentItemsEditorProps {
|
||||
items: DocumentItem[];
|
||||
onChange: (items: DocumentItem[]) => void;
|
||||
currency: string;
|
||||
readOnly: boolean;
|
||||
/** Validation error shown under the section title. */
|
||||
error?: string;
|
||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||
showIncludedInTotal?: boolean;
|
||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||
itemDescriptionMaxLength?: number;
|
||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable line-items table shared by the document detail pages (offers,
|
||||
* issued orders): drag-and-drop reorder, mobile card layout, live NET totals
|
||||
* ("Celkem bez DPH" — these documents are not tax documents).
|
||||
*/
|
||||
export default function DocumentItemsEditor({
|
||||
items,
|
||||
onChange,
|
||||
currency,
|
||||
readOnly,
|
||||
error,
|
||||
showIncludedInTotal = false,
|
||||
itemDescriptionMaxLength = 5000,
|
||||
templatesSlot,
|
||||
}: DocumentItemsEditorProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
|
||||
// pointer events and its 5px distance constraint fires before the
|
||||
// TouchSensor's long-press delay — the browser then claims the gesture for
|
||||
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
|
||||
// TouchSensor handles touch via long-press; the drag handles additionally
|
||||
// carry touch-action: none so the browser never starts a scroll from them.
|
||||
const dndSensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 300, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const updateItem = (
|
||||
index: number,
|
||||
field: keyof DocumentItem,
|
||||
value: string | boolean,
|
||||
) =>
|
||||
onChange(
|
||||
items.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
|
||||
);
|
||||
|
||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length <= 1) return;
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = items.findIndex((i) => i._key === String(active.id));
|
||||
const newIndex = items.findIndex((i) => i._key === String(over.id));
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
onChange(arrayMove(items, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const total = documentItemsTotal(items);
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DndContext
|
||||
sensors={dndSensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((i) => i._key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
currency={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer sx={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 720,
|
||||
"& td, & th": { borderColor: "divider" },
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: "2rem" }} />
|
||||
<TableCell sx={{ width: "2rem" }} align="center">
|
||||
#
|
||||
</TableCell>
|
||||
<TableCell>Popis</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Množství
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||
Jednotka
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{showIncludedInTotal && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell sx={{ width: "8rem" }} align="right">
|
||||
Celkem
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "2.5rem" }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
currency={currency}
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) =>
|
||||
updateItem(index, field, value)
|
||||
}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Totals (NET only — offers/issued orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
ml: "auto",
|
||||
maxWidth: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
pt: 0.5,
|
||||
mt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(total, currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/admin/components/document/LockBanner.tsx
Normal file
55
src/admin/components/document/LockBanner.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import type { DocumentLockInfo } from "../../hooks/useDocumentLock";
|
||||
|
||||
/**
|
||||
* Warning banner shown when another user holds the document edit lock
|
||||
* (extracted from OfferDetail; shared by the document detail pages).
|
||||
* Renders nothing when the lock is free / held by the current user.
|
||||
*
|
||||
* `entityWord` is the Czech accusative for the document ("Nabídku" /
|
||||
* "Objednávku") — both are feminine, so the rest of the sentence is shared.
|
||||
*/
|
||||
export default function LockBanner({
|
||||
lockedBy,
|
||||
entityWord,
|
||||
}: {
|
||||
lockedBy: DocumentLockInfo | null;
|
||||
entityWord: string;
|
||||
}) {
|
||||
if (!lockedBy) return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor:
|
||||
"color-mix(in srgb, var(--mui-palette-warning-main) 15%, transparent)",
|
||||
border: 1,
|
||||
borderColor: "warning.main",
|
||||
borderRadius: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
fontSize: "0.875rem",
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
{entityWord} právě upravuje <strong>{lockedBy.full_name}</strong>.
|
||||
Můžete ji pouze prohlížet.
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
src/admin/components/document/SectionsEditor.tsx
Normal file
315
src/admin/components/document/SectionsEditor.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import RichEditor from "../RichEditor";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
TextField,
|
||||
Field,
|
||||
StatusChip,
|
||||
EmptyState,
|
||||
} from "../../ui";
|
||||
|
||||
/** One bilingual rich-text PDF section (offers' scope ≡ issued orders' sections). */
|
||||
export interface DocumentSection {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const emptyDocumentSection = (): DocumentSection => ({
|
||||
title: "",
|
||||
title_cz: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
);
|
||||
|
||||
const UpIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SectionsEditorProps {
|
||||
sections: DocumentSection[];
|
||||
onChange: (sections: DocumentSection[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Card heading. */
|
||||
title?: string;
|
||||
/**
|
||||
* Document language — "CZ"/"cs" prefers the Czech section title in the
|
||||
* "Sekce N — …" header line, anything else the English one.
|
||||
*/
|
||||
language?: string;
|
||||
/** Optional page-specific control (e.g. scope-template Select) next to the add button. */
|
||||
templatesSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilingual rich-text sections editor shared by the document detail pages
|
||||
* (lifted from OfferDetail's "Rozsah projektu" card): bordered section boxes
|
||||
* with EN/CZ title fields (StatusChip badges), Quill content, up/down
|
||||
* reorder and add/remove. Offers pass their template Select via
|
||||
* `templatesSlot`; issued orders don't (no templates on POs).
|
||||
*/
|
||||
export default function SectionsEditor({
|
||||
sections,
|
||||
onChange,
|
||||
readOnly,
|
||||
title = "Rozsah projektu",
|
||||
language,
|
||||
templatesSlot,
|
||||
}: SectionsEditorProps) {
|
||||
const preferCz =
|
||||
(language ?? "").toLowerCase().startsWith("cs") ||
|
||||
(language ?? "").toUpperCase() === "CZ";
|
||||
|
||||
const updateSection = (
|
||||
index: number,
|
||||
field: keyof DocumentSection,
|
||||
value: string,
|
||||
) =>
|
||||
onChange(
|
||||
sections.map((s, i) => (i === index ? { ...s, [field]: value } : s)),
|
||||
);
|
||||
|
||||
const moveSection = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= sections.length) return;
|
||||
const arr = [...sections];
|
||||
[arr[index], arr[target]] = [arr[target], arr[index]];
|
||||
onChange(arr);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
{templatesSlot}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onChange([...sections, emptyDocumentSection()])}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
+ Přidat sekci
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={
|
||||
templatesSlot
|
||||
? 'Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.'
|
||||
: 'Žádné sekce rozsahu. Klikněte na "Přidat sekci".'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
{sections.map((section, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Sekce {idx + 1}
|
||||
{(preferCz ? section.title_cz : section.title) && (
|
||||
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
|
||||
—{" "}
|
||||
{preferCz
|
||||
? section.title_cz || section.title
|
||||
: section.title}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: "flex", gap: 0.25 }}>
|
||||
{idx > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, -1)}
|
||||
title="Posunout nahoru"
|
||||
aria-label="Posunout nahoru"
|
||||
>
|
||||
{UpIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{idx < sections.length - 1 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(idx, 1)}
|
||||
title="Posunout dolů"
|
||||
aria-label="Posunout dolů"
|
||||
>
|
||||
{DownIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() =>
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
title="Odebrat sekci"
|
||||
aria-label="Odebrat sekci"
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<StatusChip label="EN" color="info" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title}
|
||||
onChange={(e) =>
|
||||
updateSection(idx, "title", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (anglicky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<StatusChip label="CZ" color="success" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title_cz}
|
||||
onChange={(e) =>
|
||||
updateSection(idx, "title_cz", e.target.value)
|
||||
}
|
||||
placeholder="Název sekce (česky)"
|
||||
InputProps={{ readOnly }}
|
||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Field label="Obsah">
|
||||
<RichEditor
|
||||
value={section.content}
|
||||
onChange={(val) => updateSection(idx, "content", val)}
|
||||
placeholder="Obsah sekce..."
|
||||
minHeight="120px"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
remember,
|
||||
};
|
||||
}
|
||||
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
|
||||
// so anything cached from a previously logged-in user would be
|
||||
// served to this one — drop the whole cache on every login.
|
||||
queryClient.clear();
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
@@ -260,19 +265,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/login/totp`, {
|
||||
// Backup codes have their own endpoint + schema (8-char codes would
|
||||
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
|
||||
// complete the same login flow: tokens issued + refresh cookie set.
|
||||
const response = await fetch(
|
||||
isBackup
|
||||
? `${API_BASE}/totp/backup-verify`
|
||||
: `${API_BASE}/login/totp`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(
|
||||
isBackup
|
||||
? {
|
||||
login_token: loginToken,
|
||||
backup_code: code,
|
||||
remember_me: remember,
|
||||
}
|
||||
: {
|
||||
login_token: loginToken,
|
||||
totp_code: code,
|
||||
remember_me: remember,
|
||||
isBackup,
|
||||
}),
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Same reason as in login(): the cache may hold the previous
|
||||
// user's data under user-agnostic keys.
|
||||
queryClient.clear();
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
@@ -312,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
}
|
||||
// The React Query cache outlives the session — without this, the next
|
||||
// user to log in on this browser sees the previous user's cached
|
||||
// dashboards/lists until each query refetches.
|
||||
queryClient.clear();
|
||||
}
|
||||
}, [getAccessTokenFn]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
calcProjectMinutesTotal,
|
||||
calcFormWorkMinutes,
|
||||
calculateWorkMinutes,
|
||||
combineDatetime,
|
||||
getDatePart,
|
||||
getTimePart,
|
||||
formatDate,
|
||||
@@ -133,9 +134,6 @@ interface DeleteConfirmState {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const combineDatetime = (date: string, time: string): string | null =>
|
||||
date && time ? `${date}T${time}:00` : null;
|
||||
|
||||
/**
|
||||
* Compute per-user totals from raw attendance records.
|
||||
* This replaces the server-side `user_totals` that the PHP backend returned.
|
||||
@@ -869,7 +867,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
try {
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
|
||||
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`;
|
||||
// The KPI cards + summary totals are computed from THIS fetch — it
|
||||
// must cover the complete month (the server allows limit up to 2000
|
||||
// for exactly this view; 2000 ≈ 64 employees × 31 days).
|
||||
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=2000`;
|
||||
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
|
||||
|
||||
const [recordsResponse, balancesResponse] = await Promise.all([
|
||||
@@ -1047,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||
// punch-button state) — without this it serves a stale cache for up
|
||||
// to its staleTime after records change here.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -1119,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
if (result.success) {
|
||||
setShowBulkModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||
// punch-button state) — without this it serves a stale cache for up
|
||||
// to its staleTime after records change here.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -1254,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||
// punch-button state) — without this it serves a stale cache for up
|
||||
// to its staleTime after records change here.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -1284,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, record: null });
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||
// punch-button state) — without this it serves a stale cache for up
|
||||
// to its staleTime after records change here.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
await fetchData(false);
|
||||
alert.success(
|
||||
result.message || result.data?.message || "Záznam smazán",
|
||||
|
||||
81
src/admin/hooks/useDocumentLock.ts
Normal file
81
src/admin/hooks/useDocumentLock.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
/** Lock holder as enriched by the detail endpoints (offers, issued orders). */
|
||||
export interface DocumentLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side of the document edit lock shared by the offer / issued-order
|
||||
* detail pages (extracted from OfferDetail). The server exposes the same
|
||||
* trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`,
|
||||
* `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed
|
||||
* 10 s beats, so background-tab interval throttling can't hand the lock away).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - `lockedBy` state — the page hydrates it from the detail response via
|
||||
* `setLockedBy(detail.locked_by ?? null)`.
|
||||
* - `acquire()` — fire-and-forget lock acquisition; the page calls it from
|
||||
* its one-shot hydration effect when the doc is editable, the user holds
|
||||
* the edit permission and nobody else holds the lock.
|
||||
* - 10 s heartbeat while `enabled` (and not locked by another user), with
|
||||
* hard-401 self-stop: a 401 here means the access token expired AND the
|
||||
* refresh failed (apiFetch retries recoverable 401s itself), so we stop
|
||||
* the interval instead of spamming a dead session every 10 s.
|
||||
* - unlock on unmount/disable, with an AbortController so a stale in-flight
|
||||
* unlock can be cancelled by the next one.
|
||||
*/
|
||||
export function useDocumentLock({
|
||||
baseUrl,
|
||||
enabled,
|
||||
}: {
|
||||
/** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */
|
||||
baseUrl: string | null;
|
||||
/**
|
||||
* External conditions for holding the lock: edit mode + editable status +
|
||||
* edit permission. The hook adds `!isLockedByOther` itself.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [lockedBy, setLockedBy] = useState<DocumentLockInfo | null>(null);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlockAbortRef = useRef<AbortController | null>(null);
|
||||
const isLockedByOther = lockedBy !== null;
|
||||
|
||||
const acquire = useCallback(() => {
|
||||
if (!baseUrl) return;
|
||||
apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {});
|
||||
}, [baseUrl]);
|
||||
|
||||
// Heartbeat to keep the lock alive + release on unmount/disable.
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !enabled || isLockedByOther) return;
|
||||
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
apiFetch(`${baseUrl}/heartbeat`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.status === 401 && heartbeatRef.current) {
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10 * 1000); // every 10 seconds
|
||||
|
||||
return () => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
if (unlockAbortRef.current) unlockAbortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
unlockAbortRef.current = controller;
|
||||
apiFetch(`${baseUrl}/unlock`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [baseUrl, enabled, isLockedByOther]);
|
||||
|
||||
return { lockedBy, isLockedByOther, setLockedBy, acquire };
|
||||
}
|
||||
86
src/admin/hooks/useDocumentPdf.ts
Normal file
86
src/admin/hooks/useDocumentPdf.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
/**
|
||||
* Shared "Zobrazit PDF" handlers for the document pages (offers, issued
|
||||
* orders) — both call their GET `…/:id/file` endpoint (archived NAS PDF with
|
||||
* live-render fallback). Hardened flow extracted from OfferDetail:
|
||||
* - pre-opens the tab synchronously (popup blockers only allow it inside the
|
||||
* click's call stack), then streams the blob URL into it;
|
||||
* - double-click guard via a ref (state updates are async, a ref is not);
|
||||
* - 401 → silently close the tab (apiFetch already flagged the dead session);
|
||||
* - blob URLs are revoked after 60 s; pending revoke timeouts are cleared on
|
||||
* unmount so they can't fire into a dead page.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List variant — ONE hook instance serves every row. `openPdf(id, url)`
|
||||
* fetches that row's PDF; `pdfLoadingId` reports which row is in flight (use
|
||||
* it for the per-row spinner + disabled state).
|
||||
*/
|
||||
export function useDocumentListPdf() {
|
||||
const alert = useAlert();
|
||||
const [pdfLoadingId, setPdfLoadingId] = useState<number | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// The array object is stable (we only push to it, never reassign), so
|
||||
// capturing it here keeps the cleanup in sync with the ref's content.
|
||||
const timeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPdf = useCallback(
|
||||
async (id: number, url: string) => {
|
||||
if (loadingRef.current) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
loadingRef.current = true;
|
||||
setPdfLoadingId(id);
|
||||
try {
|
||||
const response = await apiFetch(url);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("Nepodařilo se vygenerovat PDF");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrl;
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setPdfLoadingId(null);
|
||||
}
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { openPdf, pdfLoadingId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail variant — a single fixed URL, `openPdf()` takes no arguments.
|
||||
* Implemented on top of the list variant so the hardened flow lives once.
|
||||
*/
|
||||
export function useDocumentPdf(url: string | null) {
|
||||
const { openPdf: openListPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const openPdf = useCallback(async () => {
|
||||
if (!url) return;
|
||||
await openListPdf(0, url);
|
||||
}, [url, openListPdf]);
|
||||
|
||||
return { openPdf, pdfLoading: pdfLoadingId !== null };
|
||||
}
|
||||
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
41
src/admin/hooks/useUnsavedChangesGuard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Dirty-form guard shared by the document detail pages (extracted from
|
||||
* OfferDetail). Serializes `snapshot` (typically `{ form, items, sections }`)
|
||||
* every render, captures a baseline on the first `ready` render — or
|
||||
* explicitly via `markClean` — and registers a `beforeunload` warning while
|
||||
* the current snapshot differs from the baseline.
|
||||
*
|
||||
* `markClean(value?)`:
|
||||
* - with a value: baseline = that value (use from a hydration effect, where
|
||||
* the freshly-mapped objects are ahead of the not-yet-rendered state);
|
||||
* - without: baseline = the last RENDERED snapshot (use after a successful
|
||||
* save, where the handler's closure state equals the rendered state).
|
||||
*/
|
||||
export function useUnsavedChangesGuard(snapshot: unknown, ready: boolean) {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const serializedRef = useRef(serialized);
|
||||
serializedRef.current = serialized;
|
||||
const baselineRef = useRef<string | null>(null);
|
||||
if (ready && baselineRef.current === null) baselineRef.current = serialized;
|
||||
const isDirty =
|
||||
baselineRef.current !== null && serialized !== baselineRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const markClean = useCallback((value?: unknown) => {
|
||||
baselineRef.current =
|
||||
value !== undefined ? JSON.stringify(value) : serializedRef.current;
|
||||
}, []);
|
||||
|
||||
return { isDirty, markClean };
|
||||
}
|
||||
@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
||||
staleTime: 60_000,
|
||||
// The dashboard aggregates MANY domains (attendance, offers, invoices,
|
||||
// orders, projects, leave). Mutations in those domains can't all be
|
||||
// expected to invalidate ["dashboard"], so always refetch on mount —
|
||||
// navigating back to the dashboard must never show pre-mutation data.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
||||
|
||||
@@ -43,6 +43,14 @@ export interface InvoiceItem {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
notes?: string;
|
||||
internal_notes?: string | null;
|
||||
payment_method?: string;
|
||||
variable_symbol?: string;
|
||||
constant_symbol?: string;
|
||||
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
|
||||
bank_account?: string;
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
sections?: InvoiceSection[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
// Single shared definition (type-only import, erased at compile time) — the
|
||||
// canonical CurrencyAmount lives in offers.ts; re-exported here so existing
|
||||
// importers keep working.
|
||||
import type { CurrencyAmount } from "./offers";
|
||||
|
||||
export type { CurrencyAmount };
|
||||
|
||||
export interface IssuedOrder {
|
||||
id: number;
|
||||
po_number: string | null;
|
||||
customer_id: number | null;
|
||||
customer_name: string | null;
|
||||
supplier_id: number | null;
|
||||
supplier_name: string | null;
|
||||
status: string;
|
||||
currency: string | null;
|
||||
order_date: string | null;
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
// NET total — issued orders carry no VAT (not tax documents).
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
|
||||
export interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
ico: string | null;
|
||||
dic: string | null;
|
||||
street: string | null;
|
||||
city: string | null;
|
||||
postal_code: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export interface IssuedOrderItem {
|
||||
id?: number;
|
||||
description: string | null;
|
||||
@@ -21,26 +38,45 @@ export interface IssuedOrderItem {
|
||||
quantity: number | string | null;
|
||||
unit: string | null;
|
||||
unit_price: number | string | null;
|
||||
vat_rate: number | string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** Rich-text CZ/EN PDF section (mirrors offers' scope sections). */
|
||||
export interface IssuedOrderSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: 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;
|
||||
order_text: string | null;
|
||||
internal_notes: string | null;
|
||||
items: IssuedOrderItem[];
|
||||
customer: Record<string, unknown> | null;
|
||||
sections: IssuedOrderSection[];
|
||||
supplier: Record<string, unknown> | null;
|
||||
valid_transitions: string[];
|
||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||
locked_by: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
|
||||
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
|
||||
export const issuedOrderSuppliersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", "suppliers"],
|
||||
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
export const issuedOrderListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -48,6 +84,7 @@ export const issuedOrderListOptions = (filters: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -61,6 +98,8 @@ export const issuedOrderListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -70,14 +109,10 @@ export const issuedOrderListOptions = (filters: {
|
||||
},
|
||||
});
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const issuedOrderStatsOptions = (filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
supplier_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) =>
|
||||
@@ -87,6 +122,8 @@ export const issuedOrderStatsOptions = (filters: {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.supplier_id)
|
||||
params.set("supplier_id", String(filters.supplier_id));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
const qs = params.toString();
|
||||
@@ -103,4 +140,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
|
||||
queryFn: () =>
|
||||
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
|
||||
enabled: !!id,
|
||||
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
||||
// GETs and keeps the detail page on an infinite spinner.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const issuedOrderNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["issued-orders", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/issued-orders/next-number",
|
||||
).then((d) => d?.next_number || d?.number || ""),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,32 @@ export interface ApiError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full success envelope (`data` + server `message`) — the result shape of a
|
||||
* mutation created with `envelope: true`. Used by the document detail pages
|
||||
* (offers, issued orders) to pass the server's Czech success message through
|
||||
* to the toast instead of a hardcoded client string.
|
||||
*/
|
||||
export interface ApiEnvelope<T> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czech-safe error message for toasts: returns the server-provided message
|
||||
* from an `ApiError` (those carry `.status` and a Czech `error` string), and
|
||||
* falls back when the message is a client-generated English string — network
|
||||
* failures ("Failed to fetch" has no status), "Invalid JSON response",
|
||||
* "Unauthorized" (401) or the generic "Request failed (NNN)".
|
||||
*/
|
||||
export function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const status = (err as ApiError | null | undefined)?.status;
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
if (!message || status === undefined || status === 401) return fallback;
|
||||
if (/^Request failed \(\d+\)$/.test(message)) return fallback;
|
||||
return message;
|
||||
}
|
||||
|
||||
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface ApiResponseBody<T> {
|
||||
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
body?: TIn;
|
||||
envelope?: boolean;
|
||||
}): Promise<TOut> {
|
||||
const url =
|
||||
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
|
||||
@@ -59,6 +86,9 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.envelope) {
|
||||
return { data: result.data, message: result.message } as TOut;
|
||||
}
|
||||
return result.data as TOut;
|
||||
}
|
||||
|
||||
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||
invalidate?: readonly string[];
|
||||
/**
|
||||
* When true, the mutation resolves with the full `ApiEnvelope` (`{ data,
|
||||
* message }`) instead of the bare `data` — declare `TOut` as
|
||||
* `ApiEnvelope<...>` accordingly.
|
||||
*/
|
||||
envelope?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +125,12 @@ export function useApiMutation<TIn, TOut>(
|
||||
opts: ApiMutationOptions<TIn, TOut> &
|
||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||
) {
|
||||
const { url, method, invalidate, onSuccess, ...rest } = opts;
|
||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TOut, ApiError, TIn, unknown>({
|
||||
mutationFn: (input: TIn) =>
|
||||
performMutation<TIn, TOut>({ url, method, body: input }),
|
||||
performMutation<TIn, TOut>({ url, method, body: input, envelope }),
|
||||
...rest,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (invalidate) {
|
||||
|
||||
@@ -52,7 +52,9 @@ export interface Customer {
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
// Under the broad ["offers"] domain so offer-domain invalidations
|
||||
// (customer CRUD, offer mutations) refresh the picker automatically.
|
||||
queryKey: ["offers", "customers"],
|
||||
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
|
||||
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||
});
|
||||
|
||||
/** Offer list row (the shape returned by GET /api/admin/offers). */
|
||||
export interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
export const offerListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<Quotation>(
|
||||
`/api/admin/offers${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,13 +176,13 @@ export interface OfferDetailData {
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
items?: OfferItemData[];
|
||||
sections?: OfferSectionData[];
|
||||
status: string;
|
||||
order: OfferOrderInfo | null;
|
||||
locked_by: OfferLockInfo | null;
|
||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||
valid_transitions: string[];
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
|
||||
@@ -43,8 +43,6 @@ export interface OrderData {
|
||||
status: string;
|
||||
notes: string;
|
||||
attachment_name?: string;
|
||||
apply_vat: number | boolean;
|
||||
vat_rate: number;
|
||||
language?: string;
|
||||
items: OrderItem[];
|
||||
sections: OrderSection[];
|
||||
|
||||
@@ -40,6 +40,7 @@ export const projectListOptions = (filters: {
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["projects", "list", filters],
|
||||
@@ -50,6 +51,7 @@ export const projectListOptions = (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);
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery<Project>(
|
||||
`/api/admin/projects${qs ? `?${qs}` : ""}`,
|
||||
|
||||
@@ -86,3 +86,13 @@ export const require2FAOptions = () =>
|
||||
queryFn: () =>
|
||||
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
|
||||
});
|
||||
|
||||
// Per-user 2FA enrollment status (users.totp_enabled) — NOT the company-wide
|
||||
// require_2fa policy above. Mutations that enable/disable 2FA must invalidate
|
||||
// the broad ["totp"] domain key.
|
||||
export const totpStatusOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["totp", "status"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ totp_enabled: boolean }>("/api/admin/totp/status"),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface TripVehicle {
|
||||
id: number;
|
||||
@@ -59,7 +59,9 @@ export const tripListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<BackendTrip>(
|
||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +83,8 @@ export const tripHistoryOptions = (filters: {
|
||||
month?: string;
|
||||
vehicleId?: number;
|
||||
userId?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
@@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: {
|
||||
month: filters.month,
|
||||
vehicleId: filters.vehicleId,
|
||||
userId: filters.userId,
|
||||
page: filters.page,
|
||||
perPage: filters.perPage,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
@@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: {
|
||||
if (filters.vehicleId)
|
||||
params.set("vehicle_id", String(filters.vehicleId));
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<BackendTrip>(
|
||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export interface TripStats {
|
||||
count: number;
|
||||
total_km: number;
|
||||
business_km: number;
|
||||
private_km: number;
|
||||
}
|
||||
|
||||
// Count/km totals over the WHOLE filtered set (not one page) — hits
|
||||
// /trips/stats with the same filters the lists use. `month` accepts either a
|
||||
// numeric month (paired with `year`, TripsAdmin style) or a combined
|
||||
// "YYYY-MM" string (TripsHistory style); the server supports both.
|
||||
export const tripStatsOptions = (filters: {
|
||||
month?: number | string;
|
||||
year?: number;
|
||||
vehicleId?: number;
|
||||
userId?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"trips",
|
||||
"stats",
|
||||
{
|
||||
month: filters.month,
|
||||
year: filters.year,
|
||||
vehicleId: filters.vehicleId,
|
||||
userId: filters.userId,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
if (filters.vehicleId)
|
||||
params.set("vehicle_id", String(filters.vehicleId));
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<TripStats>(
|
||||
`/api/admin/trips/stats${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,16 +157,23 @@ export interface WarehouseLocation {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface WarehouseSupplierCustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel: boolean;
|
||||
_key?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseSupplier {
|
||||
id: number;
|
||||
name: string;
|
||||
ico: string | null;
|
||||
dic: string | null;
|
||||
contact_person: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
street: string | null;
|
||||
city: string | null;
|
||||
postal_code: string | null;
|
||||
country: string | null;
|
||||
custom_fields?: WarehouseSupplierCustomField[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,8 @@ export default function Attendance() {
|
||||
>({
|
||||
url: () => `${API_BASE}/attendance`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance"],
|
||||
// dashboard included: the punch-button state + presence cards live there.
|
||||
invalidate: ["attendance", "dashboard"],
|
||||
});
|
||||
|
||||
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
||||
@@ -300,7 +301,8 @@ export default function Attendance() {
|
||||
>({
|
||||
url: () => `${API_BASE}/leave-requests`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
||||
// dashboard included: approvers see the pending-requests KPI there.
|
||||
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
|
||||
});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { todayLocalStr } from "../utils/formatters";
|
||||
import { combineDatetime } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -41,6 +42,24 @@ interface CreateForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire payload for POST /attendance (CreateAttendanceSchema). The raw form
|
||||
* above is NOT sent directly: its separate date+time inputs are combined into
|
||||
* "YYYY-MM-DDTHH:MM:00" datetimes (same semantics as useAttendanceAdmin's
|
||||
* create modal), and unset optional fields are null — never "".
|
||||
*/
|
||||
interface CreatePayload {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
leave_type: string;
|
||||
notes: string | null;
|
||||
leave_hours?: number;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
}
|
||||
|
||||
export default function AttendanceCreate() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -52,10 +71,10 @@ export default function AttendanceCreate() {
|
||||
}));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const createMutation = useApiMutation<CreateForm, { message?: string }>({
|
||||
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
||||
url: () => `${API_BASE}/attendance`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance", "users"],
|
||||
invalidate: ["attendance", "users", "dashboard"],
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<CreateForm>(() => {
|
||||
@@ -88,7 +107,36 @@ export default function AttendanceCreate() {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync(form);
|
||||
const isLeave = form.leave_type !== "work";
|
||||
const payload: CreatePayload = {
|
||||
user_id: Number(form.user_id),
|
||||
shift_date: form.shift_date,
|
||||
leave_type: form.leave_type,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
|
||||
if (isLeave) {
|
||||
payload.leave_hours = form.leave_hours || 8;
|
||||
} else {
|
||||
payload.arrival_time = combineDatetime(
|
||||
form.arrival_date,
|
||||
form.arrival_time,
|
||||
);
|
||||
payload.departure_time = combineDatetime(
|
||||
form.departure_date,
|
||||
form.departure_time,
|
||||
);
|
||||
payload.break_start = combineDatetime(
|
||||
form.break_start_date,
|
||||
form.break_start_time,
|
||||
);
|
||||
payload.break_end = combineDatetime(
|
||||
form.break_end_date,
|
||||
form.break_end_time,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
alert.success(result?.message || "Uloženo");
|
||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import { dashboardOptions } from "../lib/queries/dashboard";
|
||||
import { require2FAOptions } from "../lib/queries/settings";
|
||||
import { totpStatusOptions } from "../lib/queries/settings";
|
||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
||||
@@ -86,9 +86,11 @@ export default function Dashboard() {
|
||||
const { data: dashDataRaw, isPending: dashLoading } =
|
||||
useQuery(dashboardOptions());
|
||||
const dashData = dashDataRaw as DashData | undefined;
|
||||
// Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide
|
||||
// require_2fa policy flag (the banner below uses user.require2FA for that).
|
||||
const { data: totpData, isPending: totpLoading } =
|
||||
useQuery(require2FAOptions());
|
||||
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
|
||||
useQuery(totpStatusOptions());
|
||||
const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled;
|
||||
|
||||
const punchMutation = useApiMutation<
|
||||
Record<string, unknown>,
|
||||
@@ -176,6 +178,7 @@ export default function Dashboard() {
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
@@ -206,6 +209,7 @@ export default function Dashboard() {
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
|
||||
@@ -24,11 +24,14 @@ import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -51,7 +54,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices";
|
||||
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 {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
numberOr,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
@@ -193,7 +201,7 @@ interface InvoiceForm {
|
||||
constant_symbol: string;
|
||||
issued_by: string;
|
||||
billing_text: string;
|
||||
notes: string;
|
||||
internal_notes: string;
|
||||
language: string;
|
||||
bank_account_id: number | string;
|
||||
bank_name: string;
|
||||
@@ -270,7 +278,11 @@ function SortableInvoiceRow({
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{ cursor: "grab", color: "text.secondary" }}
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
color: "text.secondary",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
@@ -380,7 +392,7 @@ function SortableInvoiceRow({
|
||||
{...listeners}
|
||||
title="Přetáhnout"
|
||||
aria-label="Přetáhnout"
|
||||
sx={{ cursor: "grab", color: "text.secondary" }}
|
||||
sx={{ cursor: "grab", color: "text.secondary", touchAction: "none" }}
|
||||
>
|
||||
{DragIcon}
|
||||
</IconButton>
|
||||
@@ -503,10 +515,16 @@ export default function InvoiceDetail() {
|
||||
[],
|
||||
);
|
||||
|
||||
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
|
||||
// pointer events and its 5px distance constraint fires before the
|
||||
// TouchSensor's long-press delay — the browser then claims the gesture for
|
||||
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
|
||||
// TouchSensor handles touch via long-press; the drag handles additionally
|
||||
// carry touch-action: none so the browser never starts a scroll from them.
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 200, tolerance: 5 },
|
||||
activationConstraint: { delay: 300, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
@@ -535,7 +553,7 @@ export default function InvoiceDetail() {
|
||||
constant_symbol: "0308",
|
||||
issued_by: user?.fullName || "",
|
||||
billing_text: "",
|
||||
notes: "",
|
||||
internal_notes: "",
|
||||
language: "cs",
|
||||
bank_account_id: "",
|
||||
bank_name: "",
|
||||
@@ -545,6 +563,9 @@ export default function InvoiceDetail() {
|
||||
}));
|
||||
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
||||
// shared editor with offers/issued orders).
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_key: "inv-1",
|
||||
@@ -621,7 +642,6 @@ export default function InvoiceDetail() {
|
||||
});
|
||||
|
||||
// ─── Edit mode state ───
|
||||
const [notes, setNotes] = useState("");
|
||||
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
||||
const [statusConfirm, setStatusConfirm] = useState<{
|
||||
show: boolean;
|
||||
@@ -686,12 +706,12 @@ export default function InvoiceDetail() {
|
||||
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,
|
||||
vat_rate: numberOr(inv.vat_rate, 21),
|
||||
payment_method: inv.payment_method || "Příkazem",
|
||||
constant_symbol: inv.constant_symbol || "0308",
|
||||
issued_by: inv.issued_by || "",
|
||||
billing_text: inv.billing_text || "",
|
||||
notes: inv.notes || "",
|
||||
internal_notes: inv.internal_notes || "",
|
||||
language: inv.language || "cs",
|
||||
bank_account_id: matchedBankId,
|
||||
bank_name: inv.bank_name || "",
|
||||
@@ -700,7 +720,6 @@ export default function InvoiceDetail() {
|
||||
bank_account: inv.bank_account || "",
|
||||
};
|
||||
setForm(formData);
|
||||
setNotes(inv.notes || "");
|
||||
setInvoiceNumber(inv.invoice_number || "");
|
||||
|
||||
// Calculate dueDays from existing dates
|
||||
@@ -725,17 +744,33 @@ export default function InvoiceDetail() {
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit: item.unit || "",
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
vat_rate: Number(inv.vat_rate) || 21,
|
||||
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
||||
// column returned by the detail endpoint) — falling back to the
|
||||
// invoice-level rate only when the line carries none. Hydrating
|
||||
// from the invoice rate corrupted mixed-rate invoices on re-save.
|
||||
vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)),
|
||||
}))
|
||||
: [];
|
||||
if (mappedItems.length > 0) {
|
||||
setItems(mappedItems);
|
||||
}
|
||||
|
||||
// Populate sections from existing invoice
|
||||
const mappedSections =
|
||||
Array.isArray(inv.sections) && inv.sections.length > 0
|
||||
? inv.sections.map((s) => ({
|
||||
title: s.title || "",
|
||||
title_cz: s.title_cz || "",
|
||||
content: s.content || "",
|
||||
}))
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
|
||||
// Capture initial snapshot for dirty-checking
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form: formData,
|
||||
items: mappedItems,
|
||||
sections: mappedSections,
|
||||
});
|
||||
|
||||
setDataReady(true);
|
||||
@@ -778,11 +813,12 @@ export default function InvoiceDetail() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Pre-fill from order
|
||||
// Pre-fill from order. Orders no longer carry VAT (not tax documents) —
|
||||
// the invoice decides its own VAT: default rate from company settings,
|
||||
// apply_vat on.
|
||||
if (fromOrderId && orderDataQuery.data) {
|
||||
const order = orderDataQuery.data;
|
||||
const vatRate =
|
||||
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
|
||||
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: order.customer_id as number,
|
||||
@@ -792,7 +828,7 @@ export default function InvoiceDetail() {
|
||||
(order.currency as string) ||
|
||||
companySettings?.default_currency ||
|
||||
"CZK",
|
||||
apply_vat: Number(order.apply_vat) || 0,
|
||||
apply_vat: 1,
|
||||
vat_rate: vatRate,
|
||||
}));
|
||||
const orderItems = order.items as Record<string, unknown>[] | undefined;
|
||||
@@ -831,13 +867,15 @@ export default function InvoiceDetail() {
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
// Create mode: captured on the first render after sync effects populate the form.
|
||||
if (dataReady && !initialSnapshotRef.current) {
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
}
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!initialSnapshotRef.current) return false;
|
||||
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
|
||||
}, [form, items]);
|
||||
return (
|
||||
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
||||
);
|
||||
}, [form, items, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
@@ -995,6 +1033,12 @@ export default function InvoiceDetail() {
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
position: i,
|
||||
})),
|
||||
sections: sections.map((s, i) => ({
|
||||
title: s.title,
|
||||
title_cz: s.title_cz,
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
};
|
||||
// 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
|
||||
@@ -1007,7 +1051,7 @@ export default function InvoiceDetail() {
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
if (!isEdit) {
|
||||
navigate(`/invoices/${data.invoice_id}`);
|
||||
}
|
||||
@@ -1119,7 +1163,16 @@ export default function InvoiceDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button
|
||||
on phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices?tab=issued"
|
||||
@@ -1138,7 +1191,13 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
Faktura
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{documentNumberLabel(invoice.invoice_number)}
|
||||
</Box>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
@@ -1271,11 +1330,6 @@ export default function InvoiceDetail() {
|
||||
<Field label="Variabilní symbol">
|
||||
<Typography variant="body2">{invoice.invoice_number}</Typography>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<Typography variant="body2">
|
||||
{invoice.issued_by || "—"}
|
||||
</Typography>
|
||||
</Field>
|
||||
</Box>
|
||||
{invoice.paid_date && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
@@ -1587,14 +1641,55 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes (read-only) */}
|
||||
{/* Obsah sections (read-only) — shown only when any section has content */}
|
||||
{(invoice.sections ?? []).some(
|
||||
(s) =>
|
||||
(s.title || "").trim() ||
|
||||
(s.title_cz || "").trim() ||
|
||||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||
) && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
Obsah
|
||||
</Typography>
|
||||
{notes && notes.trim() && notes !== "<p><br></p>" ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{(invoice.sections ?? []).map((s, idx) => (
|
||||
<Box key={s.id ?? idx}>
|
||||
{((invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title) ||
|
||||
"") && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title}
|
||||
</Typography>
|
||||
)}
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Internal notes (read-only, never printed) */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Interní poznámky
|
||||
</Typography>
|
||||
{invoice.internal_notes &&
|
||||
invoice.internal_notes.trim() &&
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -1638,7 +1733,16 @@ export default function InvoiceDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: long document titles must drop below the Zpět button
|
||||
on phones instead of overflowing the viewport. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/invoices?tab=issued"
|
||||
@@ -1649,7 +1753,6 @@ export default function InvoiceDetail() {
|
||||
Zpět
|
||||
</Button>
|
||||
<Box>
|
||||
{isEdit && invoice ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -1659,24 +1762,28 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
{isEdit ? "Faktura" : "Nová faktura"}
|
||||
{/* Edit mode: a draft has no number yet → show "Koncept".
|
||||
Create mode: show the previewed next-number when available. */}
|
||||
{(isEdit || invoiceNumber) && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{isEdit
|
||||
? documentNumberLabel(invoice?.invoice_number)
|
||||
: invoiceNumber}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{isEdit && invoice && (
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
color={statusColor(INVOICE_STATUS, invoice.status)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="h4">
|
||||
Nová faktura{" "}
|
||||
{invoiceNumber && (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
({invoiceNumber})
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{fromOrderId && (
|
||||
</Box>
|
||||
{!isEdit && fromOrderId && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
@@ -1685,8 +1792,6 @@ export default function InvoiceDetail() {
|
||||
Z objednávky
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
@@ -1856,20 +1961,10 @@ export default function InvoiceDetail() {
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Číslo faktury">
|
||||
<TextField
|
||||
value={invoiceNumber}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
{/* Číslo faktury and Vystavil are not form fields anymore: the
|
||||
number lives in the page header (deferred numbering — "Koncept"
|
||||
until finalize) and issued_by is auto-filled from the logged-in
|
||||
user and still submitted with the payload (the PDF prints it). */}
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
@@ -1879,14 +1974,6 @@ export default function InvoiceDetail() {
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<TextField
|
||||
value={form.issued_by}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Field label="Text fakturace (na PDF)">
|
||||
<TextField
|
||||
@@ -2232,16 +2319,26 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
</Typography>
|
||||
<RichEditor
|
||||
value={form.notes}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
||||
placeholder="Poznámky zobrazené na faktuře..."
|
||||
{/* Rich-text PDF sections — printed after the items (like orders) */}
|
||||
<SectionsEditor
|
||||
sections={sections}
|
||||
onChange={setSections}
|
||||
readOnly={false}
|
||||
title="Obsah"
|
||||
language={form.language}
|
||||
/>
|
||||
|
||||
{/* Internal notes */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
|
||||
<RichEditor
|
||||
value={form.internal_notes}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, internal_notes: val }))
|
||||
}
|
||||
placeholder="Interní poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ 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 Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -10,18 +12,21 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
czechPlural,
|
||||
} 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 { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
issuedOrderListOptions,
|
||||
issuedOrderStatsOptions,
|
||||
issuedOrderSuppliersOptions,
|
||||
type IssuedOrder,
|
||||
} from "../lib/queries/issued-orders";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
@@ -44,9 +49,15 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
// DELIBERATE deviation from the Offers status-Tabs row: this page is embedded
|
||||
// under the parent Orders page, which already renders the identical centered
|
||||
// kit <Tabs> (Vydané/Přijaté) directly above this component — a second
|
||||
// same-styled tab row would visually fight it (and the sibling "Přijaté" tab
|
||||
// filters via a Select too). So the status filter stays a Select, with the
|
||||
// "all" entry relabeled to match the offers tabs ("Všechny").
|
||||
const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
|
||||
value: "",
|
||||
label: "Všechny stavy",
|
||||
label: "Všechny",
|
||||
});
|
||||
|
||||
const ViewIcon = (
|
||||
@@ -62,6 +73,19 @@ const ViewIcon = (
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const PdfIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [status, setStatus] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
|
||||
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 { data: suppliers } = useQuery(issuedOrderSuppliersOptions());
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the Offers list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
order: IssuedOrder | null;
|
||||
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage: 20,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
issuedOrderStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: status || undefined,
|
||||
supplier_id: supplierFilter || undefined,
|
||||
month,
|
||||
year,
|
||||
}),
|
||||
@@ -176,30 +208,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
@@ -229,10 +237,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "customer_name",
|
||||
key: "supplier_name",
|
||||
header: "Dodavatel",
|
||||
width: "24%",
|
||||
render: (o) => o.customer_name || "—",
|
||||
render: (o) => o.supplier_name || "—",
|
||||
},
|
||||
{
|
||||
key: "order_date",
|
||||
header: "Datum",
|
||||
width: "13%",
|
||||
sortKey: "order_date",
|
||||
mono: true,
|
||||
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@@ -246,14 +262,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -268,24 +276,36 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
render: (o) => (
|
||||
render: (o) => {
|
||||
// Offers' semantics: read-only rows (completed/cancelled) get the
|
||||
// view icon, editable rows get the edit icon.
|
||||
const readOnly = o.status === "completed" || o.status === "cancelled";
|
||||
return (
|
||||
<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"}
|
||||
aria-label={readOnly ? "Zobrazit" : "Upravit"}
|
||||
title={readOnly ? "Zobrazit" : "Upravit"}
|
||||
>
|
||||
{ViewIcon}
|
||||
{readOnly ? ViewIcon : EditIcon}
|
||||
</IconButton>
|
||||
{hasPermission("orders.view") && (
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("orders.view") && o.po_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleExportPdf(o)}
|
||||
aria-label="PDF"
|
||||
title="PDF"
|
||||
onClick={() =>
|
||||
openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
|
||||
}
|
||||
aria-label="Zobrazit objednávku"
|
||||
title="Zobrazit objednávku"
|
||||
disabled={pdfLoadingId === o.id}
|
||||
>
|
||||
{PdfIcon}
|
||||
{pdfLoadingId === o.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
@@ -300,7 +320,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -326,12 +347,25 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
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;
|
||||
// Search/status/supplier 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 || !!supplierFilter;
|
||||
|
||||
const total = pagination?.total ?? orders.length;
|
||||
const countLine = `${total} ${czechPlural(
|
||||
total,
|
||||
"objednávka",
|
||||
"objednávky",
|
||||
"objednávek",
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
{countLine}
|
||||
</Typography>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={supplierFilter === "" ? "" : String(supplierFilter)}
|
||||
onChange={(value) => {
|
||||
setSupplierFilter(value ? Number(value) : "");
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všichni dodavatelé" },
|
||||
...(suppliers ?? []).map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
isFiltered ? (
|
||||
<EmptyState title="Žádné objednávky neodpovídají filtru." />
|
||||
<EmptyState
|
||||
title="Žádné objednávky neodpovídají filtru"
|
||||
description="Zkuste změnit filtr nebo hledaný výraz."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku."
|
||||
: undefined
|
||||
action={
|
||||
hasPermission("orders.create") ? (
|
||||
<Button component={RouterLink} to="/orders/issued/new">
|
||||
Vytvořit objednávku
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -392,7 +447,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
deleteConfirm.order
|
||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
|
||||
? `Opravdu chcete smazat objednávku „${documentNumberLabel(deleteConfirm.order.po_number)}“? Budou smazány i všechny položky. Tato akce je nevratná.`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function LeaveApproval() {
|
||||
>({
|
||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
||||
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||
onSuccess: () => {
|
||||
setApproveModal({ open: false, request: null });
|
||||
alert.success("Žádost byla schválena");
|
||||
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
|
||||
>({
|
||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
||||
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||
onSuccess: () => {
|
||||
setRejectModal({ open: false, request: null });
|
||||
setRejectNote("");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
type Quotation,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||
label: "Všechny",
|
||||
}).filter((o) => o.value !== "expired");
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
const TemplatesIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -336,25 +324,17 @@ export default function Offers() {
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [invalidating, setInvalidating] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the issued list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
const [orderModal, setOrderModal] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
@@ -490,13 +470,10 @@ export default function Offers() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.quotation) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,37 +489,6 @@ export default function Offers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (quotation: Quotation) => {
|
||||
if (pdfLoading) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
setPdfLoading(quotation.id);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/offers/${quotation.id}/file`,
|
||||
);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setPdfLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
@@ -573,6 +519,7 @@ export default function Offers() {
|
||||
header: "Číslo",
|
||||
width: "13%",
|
||||
sortKey: "quotation_number",
|
||||
mono: true,
|
||||
render: (q) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
@@ -597,7 +544,7 @@ export default function Offers() {
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Zákazník",
|
||||
width: "13%",
|
||||
width: "16%",
|
||||
render: (q) => q.customer_name || "—",
|
||||
},
|
||||
{
|
||||
@@ -637,17 +584,10 @@ export default function Offers() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
header: "Měna",
|
||||
width: "7%",
|
||||
sortKey: "currency",
|
||||
render: (q) => <StatusChip label={q.currency} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "11%",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
@@ -693,7 +633,9 @@ export default function Offers() {
|
||||
{OrderViewIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
!readOnly &&
|
||||
// Only active offers can spawn an order — the server transition
|
||||
// guard rejects drafts/invalidated with a 400.
|
||||
q.status === "active" &&
|
||||
hasPermission("orders.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -714,15 +656,20 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("offers.view") && (
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("offers.view") && q.quotation_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
|
||||
aria-label="Zobrazit nabídku"
|
||||
title="Zobrazit nabídku"
|
||||
disabled={pdfLoading === q.id}
|
||||
disabled={pdfLoadingId === q.id}
|
||||
>
|
||||
{pdfLoading === q.id ? <CircularProgress size={16} /> : PdfIcon}
|
||||
{pdfLoadingId === q.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("offers.delete") && (
|
||||
@@ -906,7 +853,7 @@ export default function Offers() {
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -927,10 +874,10 @@ export default function Offers() {
|
||||
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat nabídku"
|
||||
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
message={`Opravdu chcete smazat nabídku „${documentNumberLabel(deleteConfirm.quotation?.quotation_number)}“? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
loading={deleteOfferMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -938,7 +885,7 @@ export default function Offers() {
|
||||
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleInvalidate}
|
||||
title="Zneplatnit nabídku"
|
||||
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
message={`Opravdu chcete zneplatnit nabídku „${documentNumberLabel(invalidateConfirm.quotation?.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
confirmText="Zneplatnit"
|
||||
confirmVariant="danger"
|
||||
loading={invalidating}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CustomField,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -198,7 +199,8 @@ export default function OffersCustomers() {
|
||||
? `${API_BASE}/customers/${editingCustomer.id}`
|
||||
: `${API_BASE}/customers`,
|
||||
method: () => (editingCustomer ? "PUT" : "POST"),
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
closeModal();
|
||||
setTimeout(
|
||||
@@ -214,7 +216,8 @@ export default function OffersCustomers() {
|
||||
>({
|
||||
url: (id) => `${API_BASE}/customers/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["offer-customers", "offers"],
|
||||
// Broad domain key — covers the ["offers","customers"] picker query too.
|
||||
invalidate: ["offers"],
|
||||
onSuccess: (data) => {
|
||||
setDeleteConfirm({ show: false, customer: null });
|
||||
alert.success(data?.message || "Zákazník byl smazán");
|
||||
@@ -265,6 +268,21 @@ export default function OffersCustomers() {
|
||||
return key;
|
||||
};
|
||||
|
||||
// ARES prefill — overwrite the company fields with registry data (the
|
||||
// button is an explicit user action, so overwriting is the expected UX).
|
||||
const fillFromAres = (c: AresCompany) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name: c.name || prev.name,
|
||||
street: c.street,
|
||||
city: c.city,
|
||||
postal_code: c.postal_code,
|
||||
country: c.country,
|
||||
company_id: c.ico,
|
||||
vat_id: c.dic || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingCustomer(null);
|
||||
setForm({
|
||||
@@ -533,6 +551,15 @@ export default function OffersCustomers() {
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="Název firmy / jméno"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="name"
|
||||
query={form.name}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ulice">
|
||||
@@ -594,6 +621,15 @@ export default function OffersCustomers() {
|
||||
company_id: e.target.value,
|
||||
}))
|
||||
}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="ico"
|
||||
query={form.company_id}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
|
||||
@@ -144,9 +144,10 @@ export default function OrderDetail() {
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
// NET only — received orders are not tax documents (no VAT on them).
|
||||
const totals = useMemo(() => {
|
||||
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
||||
const subtotal = order.items.reduce((sum, item) => {
|
||||
if (!order?.items) return { total: 0 };
|
||||
const total = order.items.reduce((sum, item) => {
|
||||
if (Number(item.is_included_in_total)) {
|
||||
return (
|
||||
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||
@@ -154,10 +155,7 @@ export default function OrderDetail() {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const vatAmount = Number(order.apply_vat)
|
||||
? subtotal * ((Number(order.vat_rate) || 0) / 100)
|
||||
: 0;
|
||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
||||
return { total };
|
||||
}, [order]);
|
||||
|
||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||
@@ -236,14 +234,12 @@ export default function OrderDetail() {
|
||||
|
||||
const handleGenerateConfirmation = async (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
customItems?: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}>,
|
||||
) => {
|
||||
setConfirmationLoading(true);
|
||||
@@ -253,7 +249,7 @@ export default function OrderDetail() {
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lang, applyVat, items: customItems }),
|
||||
body: JSON.stringify({ lang, items: customItems }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -625,7 +621,7 @@ export default function OrderDetail() {
|
||||
empty={<EmptyState title="Žádné položky." />}
|
||||
/>
|
||||
|
||||
{/* Totals */}
|
||||
{/* Totals (NET only — orders are not tax documents) */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
@@ -636,30 +632,6 @@ export default function OrderDetail() {
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mezisoučet:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(totals.subtotal, order.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{Number(order.apply_vat) > 0 && (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
DPH ({order.vat_rate}%):
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatCurrency(totals.vatAmount, order.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -671,7 +643,7 @@ export default function OrderDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Celkem k úhradě:
|
||||
Celkem bez DPH:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -829,11 +801,8 @@ export default function OrderDetail() {
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: Number(it.is_included_in_total) !== 0,
|
||||
vat_rate: Number(order.vat_rate) || 21,
|
||||
}))}
|
||||
orderNumber={order.order_number}
|
||||
defaultVatRate={Number(order.vat_rate) || 21}
|
||||
applyVat={!!order.apply_vat}
|
||||
/>
|
||||
)}
|
||||
</PageEnter>
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
CheckboxField,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
Tabs,
|
||||
type TabDef,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
@@ -54,6 +56,12 @@ const STATUS_COLORS: Record<
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
// Status filter tabs (mirrors the offers page): "" = all.
|
||||
const STATUS_TABS: TabDef[] = [
|
||||
{ value: "", label: "Všechny" },
|
||||
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
project_number: string;
|
||||
@@ -120,6 +128,7 @@ export default function Projects() {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
|
||||
@@ -172,7 +181,13 @@ export default function Projects() {
|
||||
enabled: showCreate,
|
||||
});
|
||||
|
||||
const createMutation = useApiMutation<typeof createForm, { id: number }>({
|
||||
const createMutation = useApiMutation<
|
||||
Omit<typeof createForm, "start_date" | "end_date"> & {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
},
|
||||
{ id: number }
|
||||
>({
|
||||
url: () => `${API_BASE}/projects`,
|
||||
method: () => "POST",
|
||||
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
|
||||
@@ -204,7 +219,13 @@ export default function Projects() {
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await createMutation.mutateAsync(createForm);
|
||||
// Server's isoDateString.nullish() accepts null but rejects "" —
|
||||
// send empty date fields as null so the create doesn't 400.
|
||||
await createMutation.mutateAsync({
|
||||
...createForm,
|
||||
start_date: createForm.start_date || null,
|
||||
end_date: createForm.end_date || null,
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
@@ -218,7 +239,13 @@ export default function Projects() {
|
||||
isPending,
|
||||
isFetching,
|
||||
} = usePaginatedQuery<Project>(
|
||||
projectListOptions({ search: debouncedSearch, sort, order, page }),
|
||||
projectListOptions({
|
||||
search: debouncedSearch,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
@@ -360,6 +387,31 @@ export default function Projects() {
|
||||
},
|
||||
];
|
||||
|
||||
// Per-status row tints (mirrors the offers list): LOW-ALPHA washes via the
|
||||
// palette channel vars in the badge's color — never solid `.light` fills
|
||||
// (invisible white text in dark mode). Cancelled rows are faded/muted like
|
||||
// invalidated offers.
|
||||
const rowSx = (p: Project) => {
|
||||
if (p.status === "dokonceny") {
|
||||
return {
|
||||
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (p.status === "zruseny") {
|
||||
return {
|
||||
opacity: 0.6,
|
||||
"& td": { color: "var(--mui-palette-text-secondary)" },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// Search/status filter active → empty means "nothing matches" (no CTA).
|
||||
const isFiltered = !!debouncedSearch || !!statusFilter;
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
@@ -380,6 +432,17 @@ export default function Projects() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
|
||||
<Tabs
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
tabs={STATUS_TABS}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
@@ -399,10 +462,21 @@ export default function Projects() {
|
||||
columns={columns}
|
||||
rows={projects}
|
||||
rowKey={(p) => p.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
isFiltered ? (
|
||||
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Žádné projekty neodpovídají filtru.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Zkuste změnit filtr nebo hledaný výraz.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Zatím nejsou žádné projekty.
|
||||
@@ -412,6 +486,7 @@ export default function Projects() {
|
||||
tlačítkem „Přidat projekt".
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
|
||||
@@ -184,8 +184,6 @@ export default function OrdersReceived({
|
||||
customer_id: "",
|
||||
customer_order_number: "",
|
||||
currency: "CZK",
|
||||
vat_rate: "21",
|
||||
apply_vat: true,
|
||||
scope_title: "",
|
||||
scope_description: "",
|
||||
notes: "",
|
||||
@@ -219,8 +217,6 @@ export default function OrdersReceived({
|
||||
customer_id: "",
|
||||
customer_order_number: "",
|
||||
currency: "CZK",
|
||||
vat_rate: "21",
|
||||
apply_vat: true,
|
||||
scope_title: "",
|
||||
scope_description: "",
|
||||
notes: "",
|
||||
@@ -255,8 +251,6 @@ export default function OrdersReceived({
|
||||
fd.append("customer_id", createForm.customer_id);
|
||||
fd.append("customer_order_number", createForm.customer_order_number);
|
||||
fd.append("currency", createForm.currency);
|
||||
fd.append("vat_rate", createForm.vat_rate);
|
||||
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
|
||||
fd.append("scope_title", createForm.scope_title);
|
||||
fd.append("scope_description", createForm.scope_description);
|
||||
fd.append("notes", createForm.notes);
|
||||
@@ -274,8 +268,6 @@ export default function OrdersReceived({
|
||||
: null,
|
||||
customer_order_number: createForm.customer_order_number,
|
||||
currency: createForm.currency,
|
||||
vat_rate: createForm.vat_rate,
|
||||
apply_vat: createForm.apply_vat,
|
||||
scope_title: createForm.scope_title,
|
||||
scope_description: createForm.scope_description,
|
||||
notes: createForm.notes,
|
||||
@@ -571,7 +563,7 @@ export default function OrdersReceived({
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<span>Celkem bez DPH:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
@@ -669,19 +661,6 @@ export default function OrdersReceived({
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Sazba DPH (%)">
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={createForm.vat_rate}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, vat_rate: e.target.value })
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
@@ -757,12 +736,6 @@ export default function OrdersReceived({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<CheckboxField
|
||||
label="Účtovat DPH"
|
||||
checked={createForm.apply_vat}
|
||||
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label="Vytvořit propojený projekt"
|
||||
checked={createForm.create_project}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
import {
|
||||
tripListOptions,
|
||||
tripStatsOptions,
|
||||
tripVehiclesOptions,
|
||||
type BackendTrip,
|
||||
} from "../lib/queries/trips";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
@@ -158,12 +160,20 @@ export default function Trips() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { data: tripsData, isPending: tripsLoading } = useQuery(
|
||||
tripListOptions({}),
|
||||
);
|
||||
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
||||
// "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch
|
||||
// exactly those; the stat cards come from the server-side stats endpoint
|
||||
// (computed over the whole filtered set, not one page).
|
||||
const { items: trips, isPending: tripsLoading } =
|
||||
usePaginatedQuery<BackendTrip>(tripListOptions({ perPage: 10 }));
|
||||
|
||||
const trips = tripsData ?? [];
|
||||
// Stat cards are a personal "this month" summary — the header subtitle
|
||||
// shows the current month, so the stats query is scoped to match it.
|
||||
const now = new Date();
|
||||
const { data: stats } = useQuery(
|
||||
tripStatsOptions({ month: now.getMonth() + 1, year: now.getFullYear() }),
|
||||
);
|
||||
|
||||
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
||||
const vehicles = vehiclesData ?? [];
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -321,19 +331,14 @@ export default function Trips() {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const totals = trips.reduce(
|
||||
(acc, t) => {
|
||||
const dist = t.distance ?? t.end_km - t.start_km;
|
||||
acc.count++;
|
||||
acc.total += dist;
|
||||
if (t.is_business) acc.business += dist;
|
||||
else acc.private += dist;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, business: 0, private: 0, count: 0 },
|
||||
);
|
||||
const totals = {
|
||||
count: stats?.count ?? 0,
|
||||
total: stats?.total_km ?? 0,
|
||||
business: stats?.business_km ?? 0,
|
||||
private: stats?.private_km ?? 0,
|
||||
};
|
||||
|
||||
const recentTrips = trips.slice(0, 10);
|
||||
const recentTrips = trips;
|
||||
|
||||
const columns: DataColumn<BackendTrip>[] = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import {
|
||||
tripListOptions,
|
||||
tripStatsOptions,
|
||||
tripVehiclesOptions,
|
||||
tripUsersOptions,
|
||||
type BackendTrip,
|
||||
type TripStats,
|
||||
} from "../lib/queries/trips";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
@@ -28,6 +32,7 @@ import {
|
||||
Select,
|
||||
DateField,
|
||||
MonthField,
|
||||
Pagination,
|
||||
StatusChip,
|
||||
FilterBar,
|
||||
LoadingState,
|
||||
@@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape user-origin strings before interpolating them into the print HTML
|
||||
* (the JSX hidden-div approach escaped automatically; the string template
|
||||
* must do it explicitly).
|
||||
*/
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const PRINT_STYLES = `
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
padding: 10mm;
|
||||
}
|
||||
.print-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
.print-header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.print-logo { height: 40px; width: auto; }
|
||||
.print-header-text { text-align: left; }
|
||||
.print-header-right { text-align: right; }
|
||||
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
|
||||
.print-header .company { font-size: 11px; color: #666; }
|
||||
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
||||
.print-header .filters { font-size: 10px; color: #666; }
|
||||
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.summary-item { text-align: center; }
|
||||
.summary-value { font-size: 14px; font-weight: 700; }
|
||||
.summary-label { font-size: 9px; color: #666; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
||||
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
|
||||
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
|
||||
td { font-size: 9px; }
|
||||
tr:nth-child(even) { background: #f9f9f9; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
tfoot td { background: #eee; font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-warning { background: #fef3c7; color: #d97706; }
|
||||
@media print {
|
||||
body { padding: 5mm; }
|
||||
@page { size: A4 landscape; margin: 5mm; }
|
||||
thead { display: table-header-group; }
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Build the full print document from the fetched /trips/print data set —
|
||||
* no hidden-div/innerHTML round-trip, so there is nothing to race against
|
||||
* React's commit.
|
||||
*/
|
||||
function buildPrintHtml(opts: {
|
||||
trips: Trip[];
|
||||
totals: TripStats;
|
||||
periodName: string;
|
||||
companyName: string;
|
||||
vehicleName: string | null;
|
||||
userName: string | null;
|
||||
logoUrl: string;
|
||||
}): string {
|
||||
const { trips, totals } = opts;
|
||||
|
||||
const rows = trips
|
||||
.map(
|
||||
(trip) => `
|
||||
<tr>
|
||||
<td>${formatDate(trip.trip_date)}</td>
|
||||
<td>${escapeHtml(trip.driver_name)}</td>
|
||||
<td>${escapeHtml(trip.spz)}</td>
|
||||
<td>${escapeHtml(trip.route_from)} → ${escapeHtml(trip.route_to)}</td>
|
||||
<td class="text-right">${formatKm(trip.start_km)} - ${formatKm(trip.end_km)}</td>
|
||||
<td class="text-right"><strong>${formatKm(trip.distance)} km</strong></td>
|
||||
<td class="text-center">
|
||||
<span class="badge ${trip.is_business ? "badge-success" : "badge-warning"}">
|
||||
${trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>${escapeHtml(trip.notes || "")}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kniha jízd - ${escapeHtml(opts.periodName)}</title>
|
||||
<style>${PRINT_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-header">
|
||||
<div class="print-header-left">
|
||||
<img src="${opts.logoUrl}" alt="" class="print-logo" />
|
||||
<div class="print-header-text">
|
||||
<h1>KNIHA JÍZD</h1>
|
||||
<div class="company">${escapeHtml(opts.companyName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="print-header-right">
|
||||
<div class="period">${escapeHtml(opts.periodName)}</div>
|
||||
${opts.vehicleName ? `<div class="filters">Vozidlo: ${escapeHtml(opts.vehicleName)}</div>` : ""}
|
||||
${opts.userName ? `<div class="filters">Řidič: ${escapeHtml(opts.userName)}</div>` : ""}
|
||||
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">${totals.count}</div>
|
||||
<div class="summary-label">Počet jízd</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">${formatKm(totals.total_km)} km</div>
|
||||
<div class="summary-label">Celkem</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">${formatKm(totals.business_km)} km</div>
|
||||
<div class="summary-label">Služební</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">${formatKm(totals.private_km)} km</div>
|
||||
<div class="summary-label">Soukromé</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">Datum</th>
|
||||
<th style="width: 80px">Řidič</th>
|
||||
<th style="width: 70px">Vozidlo</th>
|
||||
<th>Trasa</th>
|
||||
<th style="width: 70px" class="text-right">Stav km</th>
|
||||
<th style="width: 60px" class="text-right">Vzdálenost</th>
|
||||
<th style="width: 55px" class="text-center">Typ</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" class="text-right">Celkem:</td>
|
||||
<td class="text-right"><strong>${formatKm(totals.total_km)} km</strong></td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
const PrintIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
|
||||
});
|
||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||
const [filterUserId, setFilterUserId] = useState("");
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
|
||||
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
|
||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||
const companyName = companySettings?.company_name ?? "";
|
||||
|
||||
const { data: tripsData, isPending } = useQuery(
|
||||
tripListOptions({
|
||||
// ONE shared filter object for the list, stats AND print requests so the
|
||||
// three can never drift apart. An empty MonthField value yields
|
||||
// month/year = undefined (omitted from the request), mirroring the
|
||||
// `Number(...) || undefined` guard the queries always used.
|
||||
const filters = {
|
||||
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
||||
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
||||
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
||||
userId: filterUserId ? Number(filterUserId) : undefined,
|
||||
perPage: 100,
|
||||
}),
|
||||
);
|
||||
const trips = (tripsData ?? []).map(mapTrip);
|
||||
};
|
||||
|
||||
const {
|
||||
items: tripsItems,
|
||||
pagination,
|
||||
isPending,
|
||||
} = usePaginatedQuery<BackendTrip>(tripListOptions({ ...filters, page }));
|
||||
const trips = tripsItems.map(mapTrip);
|
||||
|
||||
// Stat cards: totals over the WHOLE filtered set (server-side aggregate),
|
||||
// not just the visible page.
|
||||
const { data: stats } = useQuery(tripStatsOptions(filters));
|
||||
|
||||
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
||||
// TIn must match the backend schema (UpdateTripSchema) shape directly —
|
||||
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
|
||||
};
|
||||
|
||||
const getPeriodName = () =>
|
||||
new Date(
|
||||
filterPeriod
|
||||
? new Date(
|
||||
Number(filterPeriod.slice(0, 4)),
|
||||
Number(filterPeriod.slice(5, 7)) - 1,
|
||||
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
|
||||
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
|
||||
: "Všechna období";
|
||||
const getSelectedVehicleName = () => {
|
||||
if (!filterVehicleId) return null;
|
||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
|
||||
return u?.name || null;
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const periodName = getPeriodName();
|
||||
|
||||
setTimeout(() => {
|
||||
if (printRef.current) {
|
||||
const content = printRef.current.innerHTML;
|
||||
const handlePrint = async () => {
|
||||
// Open the print window SYNCHRONOUSLY, inside the click's transient
|
||||
// activation — calling window.open after the network await can fall
|
||||
// outside the activation window and get popup-blocked (which previously
|
||||
// failed silently).
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kniha jízd - ${periodName}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
padding: 10mm;
|
||||
if (!printWindow) {
|
||||
alert.error("Tisk byl zablokován prohlížečem");
|
||||
return;
|
||||
}
|
||||
.print-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #333;
|
||||
|
||||
// Fetch the FULL filtered set for the printout — the list query is
|
||||
// paginated, so printing from it would truncate to the visible page.
|
||||
let printTrips: Trip[];
|
||||
let printTotals: TripStats;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
if (filters.vehicleId)
|
||||
params.set("vehicle_id", String(filters.vehicleId));
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
|
||||
`${API_BASE}/trips/print?${params.toString()}`,
|
||||
);
|
||||
printTrips = data.trips.map(mapTrip);
|
||||
printTotals = data.totals;
|
||||
} catch (e) {
|
||||
printWindow.close();
|
||||
console.error("Trip print data fetch failed:", e);
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
return;
|
||||
}
|
||||
.print-header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.print-logo { height: 40px; width: auto; }
|
||||
.print-header-text { text-align: left; }
|
||||
.print-header-right { text-align: right; }
|
||||
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
|
||||
.print-header .company { font-size: 11px; color: #666; }
|
||||
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
||||
.print-header .filters { font-size: 10px; color: #666; }
|
||||
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.summary-item { text-align: center; }
|
||||
.summary-value { font-size: 14px; font-weight: 700; }
|
||||
.summary-label { font-size: 9px; color: #666; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
||||
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
|
||||
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
|
||||
td { font-size: 9px; }
|
||||
tr:nth-child(even) { background: #f9f9f9; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
tfoot td { background: #eee; font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-warning { background: #fef3c7; color: #d97706; }
|
||||
@media print {
|
||||
body { padding: 5mm; }
|
||||
@page { size: A4 landscape; margin: 5mm; }
|
||||
thead { display: table-header-group; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// The user closed the popup while the data was loading — a deliberate
|
||||
// cancel, not an error.
|
||||
if (printWindow.closed) return;
|
||||
|
||||
// The document is built directly from the fetched data (no hidden-div /
|
||||
// setTimeout round-trip), so it can be neither unmounted nor stale.
|
||||
printWindow.document.write(
|
||||
buildPrintHtml({
|
||||
trips: printTrips,
|
||||
totals: printTotals,
|
||||
periodName: getPeriodName(),
|
||||
companyName,
|
||||
vehicleName: getSelectedVehicleName(),
|
||||
userName: getSelectedUserName(),
|
||||
logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
|
||||
}),
|
||||
);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const calculateDistance = (): number => {
|
||||
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
|
||||
};
|
||||
|
||||
const totals = {
|
||||
count: trips.length,
|
||||
total: trips.reduce((sum, t) => sum + t.distance, 0),
|
||||
business: trips
|
||||
.filter((t) => Number(t.is_business))
|
||||
.reduce((sum, t) => sum + t.distance, 0),
|
||||
count: stats?.count ?? 0,
|
||||
total: stats?.total_km ?? 0,
|
||||
business: stats?.business_km ?? 0,
|
||||
};
|
||||
|
||||
const columns: DataColumn<Trip>[] = [
|
||||
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
|
||||
>
|
||||
<Typography variant="h4">Správa knihy jízd</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{trips.length > 0 && (
|
||||
{(pagination?.total ?? 0) > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
||||
<MonthField
|
||||
value={filterPeriod}
|
||||
onChange={(val) => {
|
||||
setFilterPeriod(val);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterVehicleId}
|
||||
onChange={setFilterVehicleId}
|
||||
onChange={(value) => {
|
||||
setFilterVehicleId(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všechna vozidla" },
|
||||
...vehicles.map((v) => ({
|
||||
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterUserId}
|
||||
onChange={setFilterUserId}
|
||||
onChange={(value) => {
|
||||
setFilterUserId(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všichni řidiči" },
|
||||
...tripUsers.map((u) => ({
|
||||
@@ -632,6 +811,7 @@ export default function TripsAdmin() {
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
@@ -640,6 +820,12 @@ export default function TripsAdmin() {
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{trips.length > 0 && (
|
||||
<div ref={printRef} style={{ display: "none" }}>
|
||||
<div className="print-header">
|
||||
<div className="print-header-left">
|
||||
<img
|
||||
src="/api/admin/company-settings/logo?variant=light"
|
||||
alt=""
|
||||
className="print-logo"
|
||||
/>
|
||||
<div className="print-header-text">
|
||||
<h1>KNIHA JÍZD</h1>
|
||||
<div className="company">{companyName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="print-header-right">
|
||||
<div className="period">{getPeriodName()}</div>
|
||||
{getSelectedVehicleName() && (
|
||||
<div className="filters">
|
||||
Vozidlo: {getSelectedVehicleName()}
|
||||
</div>
|
||||
)}
|
||||
{getSelectedUserName() && (
|
||||
<div className="filters">Řidič: {getSelectedUserName()}</div>
|
||||
)}
|
||||
<div className="generated">
|
||||
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary">
|
||||
<div className="summary-item">
|
||||
<div className="summary-value">{totals.count}</div>
|
||||
<div className="summary-label">Počet jízd</div>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<div className="summary-value">{formatKm(totals.total)} km</div>
|
||||
<div className="summary-label">Celkem</div>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<div className="summary-value">
|
||||
{formatKm(totals.business)} km
|
||||
</div>
|
||||
<div className="summary-label">Služební</div>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<div className="summary-value">
|
||||
{formatKm(totals.total - totals.business)} km
|
||||
</div>
|
||||
<div className="summary-label">Soukromé</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "70px" }}>Datum</th>
|
||||
<th style={{ width: "80px" }}>Řidič</th>
|
||||
<th style={{ width: "70px" }}>Vozidlo</th>
|
||||
<th>Trasa</th>
|
||||
<th style={{ width: "70px" }} className="text-right">
|
||||
Stav km
|
||||
</th>
|
||||
<th style={{ width: "60px" }} className="text-right">
|
||||
Vzdálenost
|
||||
</th>
|
||||
<th style={{ width: "55px" }} className="text-center">
|
||||
Typ
|
||||
</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trips.map((trip) => (
|
||||
<tr key={trip.id}>
|
||||
<td>{formatDate(trip.trip_date)}</td>
|
||||
<td>{trip.driver_name}</td>
|
||||
<td>{trip.spz}</td>
|
||||
<td>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<strong>{formatKm(trip.distance)} km</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<span
|
||||
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{trip.notes || ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="text-right">
|
||||
Celkem:
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<strong>{formatKm(totals.total)} km</strong>
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
|
||||
import {
|
||||
tripHistoryOptions,
|
||||
tripStatsOptions,
|
||||
tripVehiclesOptions,
|
||||
type BackendTrip,
|
||||
} from "../lib/queries/trips";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
Card,
|
||||
DataTable,
|
||||
Select,
|
||||
MonthField,
|
||||
Pagination,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
@@ -91,18 +98,35 @@ export default function TripsHistory() {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [vehicleId, setVehicleId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
||||
const vehicles = vehiclesData;
|
||||
|
||||
const { data: tripsData, isPending } = useQuery(
|
||||
const {
|
||||
items: tripsData,
|
||||
pagination,
|
||||
isPending,
|
||||
} = usePaginatedQuery<BackendTrip>(
|
||||
tripHistoryOptions({
|
||||
month,
|
||||
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||
userId: user?.id,
|
||||
page,
|
||||
}),
|
||||
);
|
||||
|
||||
// Stat cards: month totals over the WHOLE filtered set (server-side
|
||||
// aggregate over the same filters), not just the visible page.
|
||||
const { data: stats } = useQuery(
|
||||
tripStatsOptions({
|
||||
month,
|
||||
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||
userId: user?.id,
|
||||
}),
|
||||
);
|
||||
const trips: Trip[] = (tripsData ?? []).map((t) => ({
|
||||
|
||||
const trips: Trip[] = tripsData.map((t) => ({
|
||||
id: t.id,
|
||||
trip_date: t.trip_date,
|
||||
spz: t.vehicles?.spz ?? "",
|
||||
@@ -118,14 +142,11 @@ export default function TripsHistory() {
|
||||
notes: t.notes ?? undefined,
|
||||
}));
|
||||
|
||||
const totals = trips.reduce(
|
||||
(acc, t) => ({
|
||||
total: acc.total + (t.distance || 0),
|
||||
business: acc.business + (t.is_business ? t.distance || 0 : 0),
|
||||
count: acc.count + 1,
|
||||
}),
|
||||
{ total: 0, business: 0, count: 0 },
|
||||
);
|
||||
const totals = {
|
||||
count: stats?.count ?? 0,
|
||||
total: stats?.total_km ?? 0,
|
||||
business: stats?.business_km ?? 0,
|
||||
};
|
||||
|
||||
if (!hasPermission("trips.history")) return <Forbidden />;
|
||||
|
||||
@@ -221,12 +242,21 @@ export default function TripsHistory() {
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
<MonthField
|
||||
value={month}
|
||||
onChange={(val) => {
|
||||
setMonth(val);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={vehicleId}
|
||||
onChange={(value) => setVehicleId(value)}
|
||||
onChange={(value) => {
|
||||
setVehicleId(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všechna vozidla" },
|
||||
...vehicles.map((v) => ({
|
||||
@@ -275,6 +305,7 @@ export default function TripsHistory() {
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
@@ -283,6 +314,12 @@ export default function TripsHistory() {
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</PageEnter>
|
||||
|
||||
@@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem {
|
||||
|
||||
const API_BASE = "/api/admin/warehouse/items";
|
||||
|
||||
// Must match UnitEnum in src/schemas/warehouse.schema.ts — the server rejects
|
||||
// any other value (CreateItemSchema/UpdateItemSchema), so free text here would
|
||||
// make the save 400.
|
||||
const UNIT_OPTIONS = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const;
|
||||
|
||||
interface ItemForm {
|
||||
item_number: string;
|
||||
name: string;
|
||||
@@ -449,15 +454,14 @@ export default function WarehouseItemDetail() {
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Jednotka" required error={errors.unit}>
|
||||
<TextField
|
||||
<Select
|
||||
value={form.unit}
|
||||
error={!!errors.unit}
|
||||
onChange={(e) => {
|
||||
updateForm("unit", e.target.value);
|
||||
onChange={(v) => {
|
||||
updateForm("unit", v);
|
||||
setErrors((prev) => ({ ...prev, unit: "" }));
|
||||
}}
|
||||
placeholder="ks, m, kg..."
|
||||
fullWidth
|
||||
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Link from "@mui/material/Link";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import { apiFetch } from "../utils/api";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
warehouseReceiptDetailOptions,
|
||||
@@ -161,6 +163,33 @@ export default function WarehouseReceiptDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// A plain <a href> cannot send the Authorization header, so attachments are
|
||||
// fetched via apiFetch and handed to the browser as a temporary blob URL.
|
||||
const handleDownloadAttachment = async (att: WarehouseReceiptAttachment) => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/receipts/${receipt.id}/attachments/${att.id}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
alert.error(result.error || "Nepodařilo se stáhnout přílohu");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
const confirming = confirmMutation.isPending;
|
||||
const cancelling = cancelMutation.isPending;
|
||||
|
||||
@@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() {
|
||||
width: "45%",
|
||||
bold: true,
|
||||
render: (att) => (
|
||||
<a
|
||||
href={`/api/admin/warehouse/receipts/${r.id}/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => handleDownloadAttachment(att)}
|
||||
sx={{ textAlign: "left", font: "inherit" }}
|
||||
>
|
||||
{att.file_name}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
warehouseSupplierListOptions,
|
||||
type WarehouseSupplier,
|
||||
type WarehouseSupplierCustomField,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
@@ -32,17 +35,29 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin/warehouse/suppliers";
|
||||
|
||||
// Mirror of the customers modal (minus the PDF field-order picker): the same
|
||||
// company fields + "Vlastní pole"; contact person/e-mail/phone live as custom
|
||||
// fields since the dedicated columns were dropped.
|
||||
interface SupplierForm {
|
||||
name: string;
|
||||
ico: string;
|
||||
dic: string;
|
||||
contact_person: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
const EMPTY_SUPPLIER_FORM: SupplierForm = {
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
street: "",
|
||||
city: "",
|
||||
postal_code: "",
|
||||
country: "",
|
||||
};
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const PlusIcon = (
|
||||
@@ -88,6 +103,32 @@ 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 SmallPlusIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
);
|
||||
|
||||
export default function WarehouseSuppliers() {
|
||||
const alert = useAlert();
|
||||
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] =
|
||||
useState<WarehouseSupplier | null>(null);
|
||||
const [form, setForm] = useState<SupplierForm>({
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
contact_person: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
|
||||
const [customFields, setCustomFields] = useState<
|
||||
WarehouseSupplierCustomField[]
|
||||
>([]);
|
||||
const customFieldKeyCounter = useRef(0);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [deactivateConfirm, setDeactivateConfirm] = useState<{
|
||||
@@ -131,13 +167,21 @@ export default function WarehouseSuppliers() {
|
||||
}>({ show: false, supplier: null });
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
SupplierForm,
|
||||
SupplierForm & {
|
||||
custom_fields: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel?: boolean;
|
||||
}>;
|
||||
},
|
||||
{ id?: number; message?: string }
|
||||
>({
|
||||
url: () =>
|
||||
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
||||
method: () => (editingSupplier ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
// issued-orders included: the PO form's supplier picker caches under
|
||||
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||
invalidate: ["warehouse", "issued-orders"],
|
||||
onSuccess: (data) => {
|
||||
setShowModal(false);
|
||||
alert.success(data?.message || "Dodavatel byl uložen");
|
||||
@@ -147,7 +191,9 @@ export default function WarehouseSuppliers() {
|
||||
const deleteMutation = useApiMutation<number, { message?: string }>({
|
||||
url: (id) => `${API_BASE}/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["warehouse"],
|
||||
// issued-orders included: the PO form's supplier picker caches under
|
||||
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||
invalidate: ["warehouse", "issued-orders"],
|
||||
onSuccess: (data) => {
|
||||
setDeactivateConfirm({ show: false, supplier: null });
|
||||
alert.success(data?.message || "Dodavatel byl smazán");
|
||||
@@ -163,23 +209,33 @@ export default function WarehouseSuppliers() {
|
||||
>({
|
||||
url: ({ id }) => `${API_BASE}/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["warehouse"],
|
||||
// issued-orders included: the PO form's supplier picker caches under
|
||||
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||
invalidate: ["warehouse", "issued-orders"],
|
||||
});
|
||||
|
||||
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
||||
|
||||
// ARES prefill — overwrite the company fields with registry data (the
|
||||
// button is an explicit user action, so overwriting is the expected UX).
|
||||
const fillFromAres = (c: AresCompany) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name: c.name || prev.name,
|
||||
ico: c.ico,
|
||||
dic: c.dic || "",
|
||||
street: c.street,
|
||||
city: c.city,
|
||||
postal_code: c.postal_code,
|
||||
country: c.country,
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingSupplier(null);
|
||||
setForm({
|
||||
name: "",
|
||||
ico: "",
|
||||
dic: "",
|
||||
contact_person: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
});
|
||||
setForm({ ...EMPTY_SUPPLIER_FORM });
|
||||
setCustomFields([]);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -190,12 +246,19 @@ export default function WarehouseSuppliers() {
|
||||
name: supplier.name,
|
||||
ico: supplier.ico || "",
|
||||
dic: supplier.dic || "",
|
||||
contact_person: supplier.contact_person || "",
|
||||
email: supplier.email || "",
|
||||
phone: supplier.phone || "",
|
||||
address: supplier.address || "",
|
||||
notes: supplier.notes || "",
|
||||
street: supplier.street || "",
|
||||
city: supplier.city || "",
|
||||
postal_code: supplier.postal_code || "",
|
||||
country: supplier.country || "",
|
||||
});
|
||||
setCustomFields(
|
||||
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
|
||||
? supplier.custom_fields.map((f) => ({
|
||||
...f,
|
||||
_key: `cf-${++customFieldKeyCounter.current}`,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -207,7 +270,16 @@ export default function WarehouseSuppliers() {
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
await submitMutation.mutateAsync({
|
||||
...form,
|
||||
custom_fields: customFields
|
||||
.filter((f) => f.name.trim() || f.value.trim())
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
showLabel: f.showLabel,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -275,23 +347,16 @@ export default function WarehouseSuppliers() {
|
||||
render: (s) => s.dic || "—",
|
||||
},
|
||||
{
|
||||
key: "contact_person",
|
||||
header: "Kontaktní osoba",
|
||||
width: "16%",
|
||||
render: (s) => s.contact_person || "—",
|
||||
key: "street",
|
||||
header: "Ulice",
|
||||
width: "18%",
|
||||
render: (s) => s.street || "—",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
header: "E-mail",
|
||||
width: "16%",
|
||||
render: (s) => s.email || "—",
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: "Telefon",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (s) => s.phone || "—",
|
||||
key: "city",
|
||||
header: "Město",
|
||||
width: "14%",
|
||||
render: (s) => s.city || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@@ -388,13 +453,15 @@ export default function WarehouseSuppliers() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{/* Add/Edit Modal — mirror of the customers modal (minus the PDF
|
||||
field-order picker) */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||
loading={submitMutation.isPending}
|
||||
maxWidth="md"
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
@@ -404,7 +471,53 @@ export default function WarehouseSuppliers() {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
}}
|
||||
placeholder="Název dodavatele"
|
||||
placeholder="Název firmy / jméno"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="name"
|
||||
query={form.name}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ulice">
|
||||
<TextField
|
||||
value={form.street}
|
||||
onChange={(e) => setForm({ ...form, street: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Město">
|
||||
<TextField
|
||||
value={form.city}
|
||||
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="PSČ">
|
||||
<TextField
|
||||
value={form.postal_code}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, postal_code: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Field label="Země">
|
||||
<TextField
|
||||
value={form.country}
|
||||
onChange={(e) => setForm({ ...form, country: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -419,72 +532,129 @@ export default function WarehouseSuppliers() {
|
||||
<TextField
|
||||
value={form.ico}
|
||||
onChange={(e) => setForm({ ...form, ico: e.target.value })}
|
||||
placeholder="12345678"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="ico"
|
||||
query={form.ico}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
<TextField
|
||||
value={form.dic}
|
||||
onChange={(e) => setForm({ ...form, dic: e.target.value })}
|
||||
placeholder="CZ12345678"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Dynamic custom fields — same editor as the customers modal */}
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
mb: 0.5,
|
||||
fontWeight: 600,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Vlastní pole
|
||||
</Typography>
|
||||
{customFields.map((field, idx) => (
|
||||
<Box key={field._key} sx={{ mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Field label="Kontaktní osoba">
|
||||
<TextField
|
||||
value={form.contact_person}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, contact_person: e.target.value })
|
||||
label={idx === 0 ? "Název" : undefined}
|
||||
value={field.name}
|
||||
onChange={(e) => {
|
||||
const updated = [...customFields];
|
||||
updated[idx] = { ...updated[idx], name: e.target.value };
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
placeholder="Např. Kontakt"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
alignItems: idx === 0 ? "flex-end" : "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={idx === 0 ? "Hodnota" : undefined}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...customFields];
|
||||
updated[idx] = {
|
||||
...updated[idx],
|
||||
value: e.target.value,
|
||||
};
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() =>
|
||||
setCustomFields(customFields.filter((_, i) => i !== idx))
|
||||
}
|
||||
placeholder="Jan Novák"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="E-mail">
|
||||
<TextField
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="info@firma.cz"
|
||||
/>
|
||||
</Field>
|
||||
title="Odebrat pole"
|
||||
aria-label="Odebrat pole"
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Field label="Telefon">
|
||||
<TextField
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="+420 123 456 789"
|
||||
</Box>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<CheckboxField
|
||||
label={
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
Zobrazit název v PDF
|
||||
</Typography>
|
||||
}
|
||||
checked={field.showLabel !== false}
|
||||
onChange={(checked) => {
|
||||
const updated = [...customFields];
|
||||
updated[idx] = { ...updated[idx], showLabel: checked };
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Adresa">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.address}
|
||||
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
placeholder="Ulice, město, PSČ"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Volitelné poznámky"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={SmallPlusIcon}
|
||||
onClick={() =>
|
||||
setCustomFields([
|
||||
...customFields,
|
||||
{
|
||||
name: "",
|
||||
value: "",
|
||||
showLabel: true,
|
||||
_key: `cf-${++customFieldKeyCounter.current}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Přidat pole
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Deactivate/Delete Confirmation */}
|
||||
|
||||
@@ -58,7 +58,13 @@ export const theme = createTheme({
|
||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h4: {
|
||||
fontFamily: FONT_HEADING,
|
||||
fontWeight: 700,
|
||||
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
||||
// viewports (long document titles + number). Scale down on xs only.
|
||||
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
||||
},
|
||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
button: { textTransform: "none", fontWeight: 600 },
|
||||
|
||||
88
src/admin/ui/SupplierPicker.tsx
Normal file
88
src/admin/ui/SupplierPicker.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
|
||||
import MuiTextField from "@mui/material/TextField";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { Supplier } from "../lib/queries/issued-orders";
|
||||
|
||||
interface SupplierPickerProps {
|
||||
suppliers: Supplier[];
|
||||
/** Selected supplier id (FK), or null when none chosen. */
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When set, draws the input in its error state (red border). The error TEXT
|
||||
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
|
||||
* render helperText here to avoid duplicating the message.
|
||||
*/
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
/** Optional id forwarded by <Field> for label association. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Search matches the supplier name AND the IČO, so typing either finds the
|
||||
// record. Match on a stringified "name + ico" so MUI's default
|
||||
// case-insensitive substring filtering covers both.
|
||||
const filterSuppliers = createFilterOptions<Supplier>({
|
||||
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Searchable supplier picker for issued orders (purchase orders) — a
|
||||
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
|
||||
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
|
||||
* renders bare (no label/error text); wrap it in the page's
|
||||
* <Field label error> for the label + error line.
|
||||
*/
|
||||
export default function SupplierPicker({
|
||||
suppliers,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
placeholder,
|
||||
id,
|
||||
}: SupplierPickerProps) {
|
||||
const selected = suppliers.find((s) => s.id === value) ?? null;
|
||||
return (
|
||||
<Autocomplete<Supplier>
|
||||
options={suppliers}
|
||||
value={selected}
|
||||
onChange={(_, opt) => onChange(opt ? opt.id : null)}
|
||||
getOptionLabel={(s) => s?.name ?? ""}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
filterOptions={filterSuppliers}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoHighlight
|
||||
renderOption={(props, s) => {
|
||||
const { key, ...rest } = props as typeof props & { key: string };
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Box>
|
||||
{s.name}
|
||||
{s.ico && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: "block", color: "text.secondary" }}
|
||||
>
|
||||
IČ: {s.ico}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
id={id}
|
||||
placeholder={placeholder ?? "Vyberte dodavatele…"}
|
||||
error={!!error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
|
||||
export { default as Select } from "./Select";
|
||||
export type { SelectOption } from "./Select";
|
||||
export { default as CustomerPicker } from "./CustomerPicker";
|
||||
export { default as SupplierPicker } from "./SupplierPicker";
|
||||
export { default as StatusChip } from "./StatusChip";
|
||||
export { CheckboxField } from "./Checkbox";
|
||||
export { default as Alert } from "./Alert";
|
||||
|
||||
@@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => {
|
||||
return extractTime(datetime);
|
||||
};
|
||||
|
||||
/**
|
||||
* Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the
|
||||
* combined local-datetime wire format the attendance API expects
|
||||
* ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString`
|
||||
* and parsed with `new Date(...)` as local time). Returns null when either
|
||||
* part is unset — "no value" for the optional datetime fields.
|
||||
*/
|
||||
export const combineDatetime = (date: string, time: string): string | null =>
|
||||
date && time ? `${date}T${time}:00` : null;
|
||||
|
||||
export const calcProjectMinutesTotal = (
|
||||
logs: Array<{
|
||||
project_id?: number;
|
||||
|
||||
@@ -50,6 +50,18 @@ export function todayLocalStr(): string {
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a server-returned numeric (number | decimal-string | null/undefined)
|
||||
* to a number, falling back ONLY when the value is missing or not numeric —
|
||||
* NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The
|
||||
* `Number(x) || fallback` idiom silently turns stored zeros into the fallback.
|
||||
*/
|
||||
export function numberOr(raw: unknown, fallback: number): number {
|
||||
if (raw == null || raw === "") return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
export function formatKm(km: number | string): string {
|
||||
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
|
||||
}
|
||||
|
||||
63
src/routes/admin/ares.ts
Normal file
63
src/routes/admin/ares.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { requireAnyPermission } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { aresLookupByIco, aresSearchByName } from "../../services/ares.service";
|
||||
|
||||
// Czech messages for the service's token errors.
|
||||
const ARES_ERRORS: Record<string, { message: string; status: number }> = {
|
||||
invalid_ico: { message: "IČO musí být 8 číslic", status: 400 },
|
||||
not_found: {
|
||||
message: "Subjekt s tímto IČO nebyl v ARES nalezen",
|
||||
status: 404,
|
||||
},
|
||||
ares_unavailable: {
|
||||
message: "Registr ARES je momentálně nedostupný, zkuste to později",
|
||||
status: 502,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ARES proxy — the browser cannot call ares.gov.cz directly (CORS + CSP), so
|
||||
* the customer/supplier modals go through these endpoints. Guarded by the
|
||||
* permissions of the two modals that use the prefill (customer editing and
|
||||
* warehouse supplier management); ARES data itself is public.
|
||||
*/
|
||||
export default async function aresRoutes(fastify: FastifyInstance) {
|
||||
const guard = requireAnyPermission(
|
||||
"customers.create",
|
||||
"customers.edit",
|
||||
"warehouse.manage",
|
||||
);
|
||||
|
||||
// GET /ico/:ico — single subject by IČO
|
||||
fastify.get<{ Params: { ico: string } }>(
|
||||
"/ico/:ico",
|
||||
{ preHandler: guard },
|
||||
async (request, reply) => {
|
||||
const result = await aresLookupByIco(request.params.ico);
|
||||
if ("error" in result) {
|
||||
const e = ARES_ERRORS[result.error];
|
||||
return error(reply, e.message, e.status);
|
||||
}
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /search?q=… — subjects by (part of) business name, max 10
|
||||
fastify.get<{ Querystring: { q?: string } }>(
|
||||
"/search",
|
||||
{ preHandler: guard },
|
||||
async (request, reply) => {
|
||||
const q = String(request.query.q ?? "").trim();
|
||||
if (q.length < 2) {
|
||||
return error(reply, "Zadejte alespoň 2 znaky názvu", 400);
|
||||
}
|
||||
const result = await aresSearchByName(q);
|
||||
if ("error" in result && !Array.isArray(result)) {
|
||||
const e = ARES_ERRORS[result.error];
|
||||
return error(reply, e.message, e.status);
|
||||
}
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -219,7 +219,14 @@ export default async function attendanceRoutes(
|
||||
}
|
||||
|
||||
// --- Default: paginated records list ---
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
// maxLimit must allow a COMPLETE month: the admin month view fetches all
|
||||
// records at once and computes the KPI cards / summary totals client-side
|
||||
// (useAttendanceAdmin). With the default 100-row cap, months with more
|
||||
// than 100 records were silently truncated (newest-first), so the screen
|
||||
// totals dropped the earliest days while the print stayed complete.
|
||||
const { page, limit, skip, order } = parsePagination(query, {
|
||||
maxLimit: 2000,
|
||||
});
|
||||
const isAdmin = authData.permissions.includes("attendance.manage");
|
||||
const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
|
||||
const userId =
|
||||
@@ -418,6 +425,8 @@ export default async function attendanceRoutes(
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
|
||||
@@ -9,48 +9,20 @@ import {
|
||||
CreateCustomerSchema,
|
||||
UpdateCustomerSchema,
|
||||
} from "../../schemas/customers.schema";
|
||||
import {
|
||||
encodeCustomFields,
|
||||
decodeCustomFields as decodeCustomFieldsShared,
|
||||
} from "../../utils/custom-fields";
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
|
||||
|
||||
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
|
||||
function encodeCustomFields(
|
||||
fields: unknown,
|
||||
fieldOrder: unknown,
|
||||
): string | null {
|
||||
const f = Array.isArray(fields) ? fields : [];
|
||||
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
|
||||
if (f.length === 0 && o.length === 0) return null;
|
||||
return JSON.stringify({ fields: f, field_order: o });
|
||||
}
|
||||
|
||||
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
|
||||
/** Customer-shaped wrapper over the shared codec (field_order key naming). */
|
||||
function decodeCustomFields(raw: string | null): {
|
||||
custom_fields: unknown[];
|
||||
customer_field_order: string[];
|
||||
} {
|
||||
if (!raw) return { custom_fields: [], customer_field_order: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// PHP format: { fields: [...], field_order: [...] }
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
"fields" in parsed
|
||||
) {
|
||||
return {
|
||||
custom_fields: parsed.fields || [],
|
||||
customer_field_order: parsed.field_order || [],
|
||||
};
|
||||
}
|
||||
// Legacy TS format: raw array
|
||||
if (Array.isArray(parsed)) {
|
||||
return { custom_fields: parsed, customer_field_order: [] };
|
||||
}
|
||||
return { custom_fields: [], customer_field_order: [] };
|
||||
} catch {
|
||||
return { custom_fields: [], customer_field_order: [] };
|
||||
}
|
||||
const { custom_fields, field_order } = decodeCustomFieldsShared(raw);
|
||||
return { custom_fields, customer_field_order: field_order };
|
||||
}
|
||||
|
||||
export default async function customersRoutes(
|
||||
|
||||
@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const now = new Date();
|
||||
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
|
||||
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
|
||||
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
|
||||
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
|
||||
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
|
||||
const todayStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||
);
|
||||
const todayEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 1,
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||
);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
@@ -2,12 +2,20 @@ import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { getRate } from "../../services/exchange-rates";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
logoDataUriFromSettings,
|
||||
buildPdfHeaderTemplate,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
@@ -16,62 +24,6 @@ const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -282,6 +234,47 @@ const translations: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
|
||||
function stripTags(html: string | null | undefined): string {
|
||||
return (html || "").replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page footer for the invoice PDF, rendered by Puppeteer in the
|
||||
* bottom page margin (mirrors buildIssuedOrderPdfFooter): "Vystavil: <name>"
|
||||
* left, "Strana X z Y" / "Page X of Y" (by document language) centered.
|
||||
* Styles must stay inline and the font-size explicit — Chromium defaults
|
||||
* footer text to 0.
|
||||
*/
|
||||
export function buildInvoicePdfFooter(
|
||||
lang: string,
|
||||
issuerLine: string,
|
||||
): string {
|
||||
const t = translations[lang] || translations.cs;
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page header for the invoice PDF — the unified red-accent
|
||||
* family header (shared with issued orders): logo left, red
|
||||
* "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page.
|
||||
* The body's own header is print-hidden so page 1 doesn't show it twice.
|
||||
*/
|
||||
export function buildInvoicePdfHeader(
|
||||
lang: string,
|
||||
logoDataUri: string,
|
||||
invoiceNumber: string,
|
||||
): string {
|
||||
const t = translations[lang] || translations.cs;
|
||||
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`);
|
||||
}
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function invoicesPdfRoutes(
|
||||
@@ -314,6 +307,11 @@ export default async function invoicesPdfRoutes(
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
const sections = await prisma.invoice_sections.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
@@ -350,16 +348,12 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// Logo as a data URI — used by the body header (screen/HTML view) AND
|
||||
// the Puppeteer headerTemplate (templates can only load data: URLs).
|
||||
const logoDataUri = logoDataUriFromSettings(settings);
|
||||
const logoImg = logoDataUri
|
||||
? `<img src="${logoDataUri}" class="logo" />`
|
||||
: "";
|
||||
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
@@ -522,26 +516,44 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
// ── Sections ("Obsah") — flow INLINE right after the items/totals
|
||||
// block (mirrors issued orders): no separate page, long content
|
||||
// paginates naturally and the Puppeteer footer template stamps every
|
||||
// page. Suppressed entirely when every section is empty (tag-stripped
|
||||
// check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
|
||||
// the Czech title when present; en (or a missing Czech title) falls
|
||||
// back to the EN title.
|
||||
const resolveSectionTitle = (s: {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
}): string =>
|
||||
lang === "cs" && (s.title_cz || "").trim()
|
||||
? s.title_cz || ""
|
||||
: s.title || "";
|
||||
// Visibility must use the SAME per-language title rule as the render
|
||||
// loop — otherwise a section with only the other language's title
|
||||
// would count as content and produce an empty sections block.
|
||||
const visibleSections = sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
||||
);
|
||||
let sectionsHtml = "";
|
||||
if (visibleSections.length > 0) {
|
||||
sectionsHtml += '<div class="scope-sections">';
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
sectionsHtml += '<div class="scope-section">';
|
||||
if (title.trim())
|
||||
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
sectionsHtml += "</div>";
|
||||
}
|
||||
sectionsHtml += "</div>";
|
||||
}
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
@@ -552,7 +564,11 @@ export default async function invoicesPdfRoutes(
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 8mm 12mm 10mm 12mm;
|
||||
/* Chromium uses THESE margins for layout (they override Puppeteer's
|
||||
margin option) — they must match the header/footer template space:
|
||||
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
|
||||
Strana X z Y). Same geometry as the issued-order PDF. */
|
||||
margin: 32mm 12mm 18mm 12mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -565,11 +581,19 @@ export default async function invoicesPdfRoutes(
|
||||
.invoice-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(297mm - 27mm);
|
||||
/* Printable area with the Puppeteer header (32mm top) + footer (18mm
|
||||
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
|
||||
min-height: calc(297mm - 51mm);
|
||||
}
|
||||
.invoice-content { flex: 1 1 auto; }
|
||||
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
|
||||
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
|
||||
at the page bottom; when the content overflows, break-inside moves the
|
||||
whole block to the next page as one unit instead of stranding the notice
|
||||
on the previous page. */
|
||||
.invoice-footer {
|
||||
flex-shrink: 0;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.accent { color: #de3a3a; }
|
||||
@@ -781,13 +805,8 @@ export default async function invoicesPdfRoutes(
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
/* Vystavil */
|
||||
.issued-by {
|
||||
font-size: 9pt;
|
||||
margin: 2mm 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.issued-by .lbl { font-weight: 600; }
|
||||
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
|
||||
every page — same as the issued-order PDF; no body block. */
|
||||
|
||||
/* Upozorneni */
|
||||
.notice {
|
||||
@@ -853,32 +872,42 @@ export default async function invoicesPdfRoutes(
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Poznamky */
|
||||
.invoice-notes {
|
||||
margin-top: 4mm;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||
stranu (mirrors issued-orders-pdf). */
|
||||
.scope-sections {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
.invoice-notes-label {
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
.scope-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 3mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.scope-section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
.section-content {
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
.invoice-notes-content h2 { font-size: 18px; }
|
||||
.invoice-notes-content h3 { font-size: 16px; }
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
.section-content h2 { font-size: 18px; }
|
||||
.section-content h3 { font-size: 16px; }
|
||||
.section-content h4 { font-size: 15px; }
|
||||
.section-content p { margin: 0 0 0.4em 0; }
|
||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
@@ -889,6 +918,10 @@ ${indentCSS}
|
||||
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
|
||||
page — hide the body copy so page 1 doesn't show it twice. The screen
|
||||
(HTML preview) keeps the body header. */
|
||||
.invoice-header { display: none; }
|
||||
}
|
||||
@media screen {
|
||||
html { background: #525659; }
|
||||
@@ -1000,16 +1033,12 @@ ${indentCSS}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${notesHtml}
|
||||
${sectionsHtml}
|
||||
|
||||
</div><!-- /.invoice-content -->
|
||||
<div class="invoice-footer">
|
||||
|
||||
<!-- Vystavil -->
|
||||
<div class="issued-by">
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
||||
</div>
|
||||
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
|
||||
|
||||
<!-- Upozorneni -->
|
||||
<div class="notice">
|
||||
@@ -1074,7 +1103,20 @@ ${indentCSS}
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
// Per-page "Vystavil + Strana X z Y" footer (same as issued
|
||||
// orders); the supplier e-mail rides along on the left (it used to
|
||||
// print under the body Vystavil block).
|
||||
const issuerLine = `${invoice.issued_by || ""}${
|
||||
suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : ""
|
||||
}`;
|
||||
const pdfBuffer = await htmlToPdf(html, {
|
||||
headerTemplate: buildInvoicePdfHeader(
|
||||
lang,
|
||||
logoDataUri,
|
||||
invoice.invoice_number || "",
|
||||
),
|
||||
footerTemplate: buildInvoicePdfFooter(lang, issuerLine),
|
||||
});
|
||||
nasInvoicesManager.saveIssuedPdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
|
||||
@@ -2,65 +2,25 @@ import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
logoDataUriFromSettings,
|
||||
buildPdfHeaderTemplate,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
/* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
@@ -156,6 +116,70 @@ function buildAddressLines(
|
||||
return { name, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Address block for the sklad_suppliers counterparty — full customer model
|
||||
* (structured street/city/postal_code/country + custom_fields JSON; the old
|
||||
* dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
|
||||
* the supplier's ico/dic columns, prefixed with the same translated labels
|
||||
* the customer block uses; custom fields render as appended lines in array
|
||||
* order (suppliers have no PDF field-order picker).
|
||||
*/
|
||||
function buildSupplierLines(
|
||||
supplier: Record<string, unknown> | null,
|
||||
tObj: Record<string, string>,
|
||||
): AddressResult {
|
||||
if (!supplier) return { name: "", lines: [] };
|
||||
const name = String(supplier.name || "");
|
||||
const lines: string[] = [];
|
||||
if (supplier.street) lines.push(String(supplier.street));
|
||||
const cityLine = [supplier.postal_code, supplier.city]
|
||||
.filter(Boolean)
|
||||
.map(String)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (cityLine) lines.push(cityLine);
|
||||
if (supplier.country) lines.push(String(supplier.country));
|
||||
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
|
||||
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
|
||||
|
||||
// Custom fields ("Vlastní pole") — same blob format as customers; malformed
|
||||
// JSON degrades to no extra lines rather than failing the PDF.
|
||||
if (supplier.custom_fields) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed =
|
||||
typeof supplier.custom_fields === "string"
|
||||
? JSON.parse(supplier.custom_fields)
|
||||
: supplier.custom_fields;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const fields =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? ((parsed as Record<string, unknown>).fields as Array<{
|
||||
name?: string;
|
||||
value?: string;
|
||||
showLabel?: boolean;
|
||||
}>)
|
||||
: Array.isArray(parsed)
|
||||
? (parsed as Array<{
|
||||
name?: string;
|
||||
value?: string;
|
||||
showLabel?: boolean;
|
||||
}>)
|
||||
: [];
|
||||
for (const f of fields ?? []) {
|
||||
const value = (f?.value || "").trim();
|
||||
if (!value) continue;
|
||||
const label = (f?.name || "").trim();
|
||||
lines.push(
|
||||
f?.showLabel !== false && label ? `${label}: ${value}` : value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { name, lines };
|
||||
}
|
||||
|
||||
/* ── Translations ────────────────────────────────────────────────── */
|
||||
|
||||
type Lang = "cs" | "en";
|
||||
@@ -165,27 +189,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
title: "OBJEDNÁVKA",
|
||||
heading: "OBJEDNÁVKA č.",
|
||||
// PO direction: WE (company) are the buyer (Odběratel), the
|
||||
// selected customer record is the supplier (Dodavatel).
|
||||
// selected sklad_suppliers record is the supplier (Dodavatel).
|
||||
supplier: "Dodavatel",
|
||||
buyer: "Odběratel",
|
||||
issue_date: "Datum vystavení:",
|
||||
delivery_date: "Požadované dodání:",
|
||||
billing: "Objednáváme u Vás:",
|
||||
billing: "Objednáváme si u Vás:",
|
||||
col_no: "Č.",
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
delivery_terms: "Dodací podmínky:",
|
||||
payment_terms: "Platební podmínky:",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
issued_by: "Vystavil:",
|
||||
ico: "IČ: ",
|
||||
dic: "DIČ: ",
|
||||
@@ -202,17 +217,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
delivery_terms: "Delivery terms:",
|
||||
payment_terms: "Payment terms:",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
issued_by: "Issued by:",
|
||||
ico: "Reg. No.: ",
|
||||
dic: "Tax ID: ",
|
||||
@@ -224,12 +230,8 @@ interface IssuedOrderPdfData {
|
||||
order_date: Date | null;
|
||||
delivery_date: Date | null;
|
||||
currency: string | null;
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: unknown;
|
||||
notes: string | null;
|
||||
delivery_terms: string | null;
|
||||
payment_terms: string | null;
|
||||
issued_by: string | null;
|
||||
// Editable heading above the items table; null → t.billing default.
|
||||
order_text?: string | null;
|
||||
}
|
||||
|
||||
interface IssuedOrderPdfItem {
|
||||
@@ -238,72 +240,98 @@ interface IssuedOrderPdfItem {
|
||||
quantity: unknown;
|
||||
unit: string | null;
|
||||
unit_price: unknown;
|
||||
vat_rate: unknown;
|
||||
}
|
||||
|
||||
export interface IssuedOrderPdfSection {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
|
||||
function stripTags(html: string | null | undefined): string {
|
||||
return (html || "").replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page footer for the issued-order PDF, rendered by Puppeteer
|
||||
* in the bottom page margin (the only true repeating footer Chromium
|
||||
* supports): "Vystavil: <name>" left, "Strana X z Y" / "Page X of Y"
|
||||
* (by document language) centered. Styles must stay inline and the
|
||||
* font-size explicit — Chromium defaults footer text to 0.
|
||||
*/
|
||||
export function buildIssuedOrderPdfFooter(
|
||||
lang: Lang,
|
||||
issuerName: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeating per-page header for the issued-order PDF — the unified
|
||||
* red-accent family header (shared with invoices): logo left, red
|
||||
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
|
||||
* own header is print-hidden so page 1 doesn't show it twice.
|
||||
*/
|
||||
export function buildIssuedOrderPdfHeader(
|
||||
lang: Lang,
|
||||
logoDataUri: string,
|
||||
poNumber: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
|
||||
}
|
||||
|
||||
export function renderIssuedOrderHtml(
|
||||
order: IssuedOrderPdfData,
|
||||
items: IssuedOrderPdfItem[],
|
||||
customer: Record<string, unknown> | null,
|
||||
supplier: Record<string, unknown> | null,
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: Lang,
|
||||
issuer: { name: string },
|
||||
// Kept for signature stability across the many call sites: the issuer no
|
||||
// longer renders in the BODY — it prints in the Puppeteer footer template
|
||||
// (buildIssuedOrderPdfFooter) on every page.
|
||||
_issuer: { name: string },
|
||||
sections: IssuedOrderPdfSection[] = [],
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const applyVat = order.apply_vat !== false;
|
||||
const currency = order.currency || "CZK";
|
||||
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
|
||||
const poNumber = escapeHtml(order.po_number || "");
|
||||
|
||||
// Logo embedding (same logic as the confirmation template).
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
|
||||
const logoDataUri = logoDataUriFromSettings(settings);
|
||||
const logoImg = logoDataUri
|
||||
? `<img src="${logoDataUri}" class="logo" />`
|
||||
: "";
|
||||
|
||||
// PO direction: our company (settings) = Odběratel (buyer);
|
||||
// the customer record = Dodavatel (supplier).
|
||||
// the sklad_suppliers record = Dodavatel (supplier).
|
||||
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
||||
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
|
||||
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
||||
|
||||
const buyerLinesHtml = buyer.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const supplierLinesHtml = supplier.lines
|
||||
const supplierLinesHtml = supplierAddr.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Items — NET + per-line VAT-on-top, rounded per line (same math the
|
||||
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
// Items — NET only. A purchase order is not a tax document: no VAT columns,
|
||||
// no VAT math (same as the service computeIssuedOrderTotals).
|
||||
let total = 0;
|
||||
const itemsHtml = items
|
||||
.map((it, i) => {
|
||||
const qty = Number(it.quantity) || 0;
|
||||
const unitPrice = Number(it.unit_price) || 0;
|
||||
const rate =
|
||||
it.vat_rate != null && it.vat_rate !== ""
|
||||
? Number(it.vat_rate)
|
||||
: docRate;
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const lineVat = applyVat
|
||||
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
|
||||
: 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
|
||||
subtotal += lineSubtotal;
|
||||
totalVat += lineVat;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
vatSummary[key].vat += lineVat;
|
||||
const lineTotal = qty * unitPrice;
|
||||
total += lineTotal;
|
||||
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const descHtml = `${escapeHtml(it.description)}${
|
||||
@@ -315,63 +343,53 @@ export function renderIssuedOrderHtml(
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${descHtml}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(unitPrice)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
<td class="right">${formatCurrency(unitPrice, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
subtotal = Math.round(subtotal * 100) / 100;
|
||||
totalVat = Math.round(totalVat * 100) / 100;
|
||||
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
total = Math.round(total * 100) / 100;
|
||||
|
||||
const issueDateStr = formatDate(order.order_date);
|
||||
const deliveryDateStr = formatDate(order.delivery_date);
|
||||
|
||||
// Order-specific terms (purchase-order addition; not on invoices).
|
||||
let termsHtml = "";
|
||||
if (order.delivery_terms) {
|
||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
|
||||
}
|
||||
if (order.payment_terms) {
|
||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
|
||||
}
|
||||
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
// ── Sections ("Obsah") — flow INLINE after the items/totals block (user
|
||||
// decision: no separate page, no repeated header). Each section keeps
|
||||
// break-inside: avoid; long content paginates naturally and the Puppeteer
|
||||
// footer template stamps every page. Suppressed entirely when every
|
||||
// section is empty (tag-stripped check, so an empty-Quill "<p><br></p>"
|
||||
// doesn't count). cs prefers the Czech title when present; en (or a
|
||||
// missing Czech title) falls back to the EN title (same rule as offers).
|
||||
const resolveSectionTitle = (s: IssuedOrderPdfSection): string =>
|
||||
lang === "cs" && (s.title_cz || "").trim()
|
||||
? s.title_cz || ""
|
||||
: s.title || "";
|
||||
// Visibility must use the SAME per-language title rule as the render loop —
|
||||
// otherwise a section with only the other language's title would count as
|
||||
// content and produce an empty sections block.
|
||||
const visibleSections = sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
||||
);
|
||||
const hasSectionContent = visibleSections.length > 0;
|
||||
|
||||
let scopeHtml = "";
|
||||
if (hasSectionContent) {
|
||||
scopeHtml += '<div class="scope-sections">';
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title.trim())
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -382,7 +400,11 @@ export function renderIssuedOrderHtml(
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 8mm 12mm 10mm 12mm;
|
||||
/* Chromium uses THESE margins for layout (they override Puppeteer's
|
||||
margin option) — they must match the header/footer template space:
|
||||
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
|
||||
Strana X z Y). Same geometry as the invoice PDF. */
|
||||
margin: 32mm 12mm 18mm 12mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -392,15 +414,9 @@ export function renderIssuedOrderHtml(
|
||||
width: 186mm;
|
||||
}
|
||||
|
||||
.invoice-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(297mm - 27mm);
|
||||
}
|
||||
.invoice-content { flex: 1 1 auto; }
|
||||
.invoice-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
|
||||
footerTemplate (bottom page margin), NOT in the body — the old flex
|
||||
min-height pinning is gone with it. */
|
||||
|
||||
.accent { color: #de3a3a; }
|
||||
|
||||
@@ -450,7 +466,7 @@ export function renderIssuedOrderHtml(
|
||||
vertical-align: top;
|
||||
width: 50%;
|
||||
}
|
||||
.header-grid td.addr-customer {
|
||||
.header-grid td.addr-supplier {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.header-grid td.details-bank {
|
||||
@@ -605,34 +621,6 @@ export function renderIssuedOrderHtml(
|
||||
border-bottom: 2.5pt solid #de3a3a;
|
||||
padding-bottom: 1mm;
|
||||
}
|
||||
.totals .currency-note {
|
||||
text-align: right;
|
||||
font-size: 8pt;
|
||||
color: #1a1a1a;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
/* Dodaci / platebni podminky (PO-specificke) */
|
||||
.terms {
|
||||
margin-top: 4mm;
|
||||
font-size: 9pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.terms-row { margin-bottom: 1mm; }
|
||||
.terms-row .lbl {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Vystavil */
|
||||
.issued-by {
|
||||
font-size: 9pt;
|
||||
margin: 2mm 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.issued-by .lbl { font-weight: 600; }
|
||||
|
||||
/* Upozorneni */
|
||||
.notice {
|
||||
font-size: 8pt;
|
||||
@@ -641,32 +629,42 @@ export function renderIssuedOrderHtml(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Poznamky */
|
||||
.invoice-notes {
|
||||
margin-top: 4mm;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||
stranu. */
|
||||
.scope-sections {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
.invoice-notes-label {
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
.scope-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 3mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.scope-section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
.section-content {
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
.invoice-notes-content h2 { font-size: 18px; }
|
||||
.invoice-notes-content h3 { font-size: 16px; }
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
.section-content h2 { font-size: 18px; }
|
||||
.section-content h3 { font-size: 16px; }
|
||||
.section-content h4 { font-size: 15px; }
|
||||
.section-content p { margin: 0 0 0.4em 0; }
|
||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
@@ -677,6 +675,10 @@ ${indentCSS}
|
||||
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
|
||||
page — hide the body copy so page 1 doesn't show it twice. The screen
|
||||
(HTML preview) keeps the body header. */
|
||||
.invoice-header { display: none; }
|
||||
}
|
||||
@media screen {
|
||||
html { background: #525659; }
|
||||
@@ -688,6 +690,7 @@ ${indentCSS}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -715,7 +718,7 @@ ${indentCSS}
|
||||
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
||||
</div>
|
||||
|
||||
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
|
||||
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
|
||||
<table class="header-grid" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
@@ -723,9 +726,9 @@ ${indentCSS}
|
||||
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
||||
${buyerLinesHtml}
|
||||
</td>
|
||||
<td class="addr-customer">
|
||||
<td class="addr-supplier">
|
||||
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||||
<div class="address-name">${escapeHtml(supplier.name)}</div>
|
||||
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
|
||||
${supplierLinesHtml}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -739,18 +742,15 @@ ${indentCSS}
|
||||
</table>
|
||||
|
||||
<!-- Polozky -->
|
||||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
||||
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -758,34 +758,21 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval;
|
||||
mena je soucasti castky pres formatCurrency, takze zadna "Částky jsou
|
||||
uvedeny v …" poznamka) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(t.total)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatCurrency(total, currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${notesHtml}
|
||||
|
||||
${termsBlock}
|
||||
${scopeHtml}
|
||||
|
||||
</div><!-- /.invoice-content -->
|
||||
<div class="invoice-footer">
|
||||
|
||||
<!-- Vystavil (jmeno prihlaseneho uzivatele) -->
|
||||
<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div>
|
||||
|
||||
</div><!-- /.invoice-footer -->
|
||||
</div><!-- /.invoice-page -->
|
||||
|
||||
</body>
|
||||
@@ -804,7 +791,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang: Lang = query.lang === "en" ? "en" : "cs";
|
||||
|
||||
try {
|
||||
const order = await prisma.issued_orders.findUnique({ where: { id } });
|
||||
@@ -814,13 +800,26 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
// Language comes from the document's own column; ?lang= is an
|
||||
// explicit override only (mirrors the /:id/file fallback semantics).
|
||||
const lang: Lang =
|
||||
query.lang === "en" || query.lang === "cs"
|
||||
? query.lang
|
||||
: order.language === "en"
|
||||
? "en"
|
||||
: "cs";
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const customer = order.customer_id
|
||||
? ((await prisma.customers.findUnique({
|
||||
where: { id: order.customer_id },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
const sections = await prisma.issued_order_sections.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const supplier = order.supplier_id
|
||||
? ((await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: order.supplier_id },
|
||||
})) as Record<string, unknown> | null)
|
||||
: null;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
@@ -838,10 +837,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
customer,
|
||||
supplier,
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
sections,
|
||||
);
|
||||
|
||||
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
||||
@@ -852,7 +852,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
? new Date(order.order_date)
|
||||
: new Date();
|
||||
nasOrdersManager.cleanIssued(order.po_number);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const pdfBuffer = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
nasOrdersManager.saveIssuedPdf(
|
||||
order.po_number,
|
||||
baseDate.getFullYear(),
|
||||
@@ -862,7 +869,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
return success(reply, null, 200, "PDF uloženo");
|
||||
}
|
||||
|
||||
const pdf = await htmlToPdf(html);
|
||||
const pdf = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
@@ -17,6 +18,20 @@ import {
|
||||
deleteIssuedOrder,
|
||||
getNextIssuedOrderNumberPreview,
|
||||
} from "../../services/issued-orders.service";
|
||||
import {
|
||||
renderIssuedOrderHtml,
|
||||
buildIssuedOrderPdfFooter,
|
||||
buildIssuedOrderPdfHeader,
|
||||
} from "./issued-orders-pdf";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
|
||||
// 3× the client's 10s heartbeat: a single missed beat (background-tab
|
||||
// throttling, transient network) must not silently hand the lock to a second
|
||||
// editor. Keep in sync with quotations.ts and useDocumentLock.
|
||||
const LOCK_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/admin/issued-orders
|
||||
@@ -25,16 +40,16 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
const result = await listIssuedOrders({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ""),
|
||||
sort,
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
});
|
||||
@@ -55,9 +70,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed
|
||||
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the
|
||||
// literal "stats" path isn't captured as an order id.
|
||||
// GET /api/admin/issued-orders/stats — per-currency NET total summed over
|
||||
// the WHOLE filtered set (not one page). Issued orders are not tax documents
|
||||
// — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
|
||||
// isn't captured as an order id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
@@ -66,7 +82,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
const result = await getIssuedOrderTotals({
|
||||
search: query.search ? String(query.search) : undefined,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
});
|
||||
@@ -74,6 +90,41 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
|
||||
// the PO supplier picker. Guarded by the ORDERS permissions (any of
|
||||
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
|
||||
// gated by warehouse.manage, which orders users typically lack — without
|
||||
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
|
||||
// so the literal "suppliers" path isn't captured as an order id.
|
||||
fastify.get(
|
||||
"/suppliers",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"orders.view",
|
||||
"orders.create",
|
||||
"orders.edit",
|
||||
),
|
||||
},
|
||||
async (_request, reply) => {
|
||||
const suppliers = await prisma.sklad_suppliers.findMany({
|
||||
where: { is_active: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ico: true,
|
||||
dic: true,
|
||||
street: true,
|
||||
city: true,
|
||||
postal_code: true,
|
||||
country: true,
|
||||
},
|
||||
// id tiebreak so same-name suppliers sort deterministically.
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
});
|
||||
return success(reply, suppliers);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/:id
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
@@ -83,7 +134,222 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
if (id === null) return;
|
||||
const order = await getIssuedOrder(id);
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, order);
|
||||
|
||||
// `getIssuedOrder` already loaded locked_by/locked_at — enrich locked_by
|
||||
// to {user_id, username, full_name} ONLY when the lock is fresh and held
|
||||
// by ANOTHER user (same shape as offers so the frontend lock hook can be
|
||||
// shared); otherwise null.
|
||||
let lockedBy: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null = null;
|
||||
if (order.locked_by && order.locked_at) {
|
||||
const lockAge = Date.now() - new Date(order.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
order.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: order.locked_by },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
});
|
||||
if (lockUser) {
|
||||
lockedBy = {
|
||||
user_id: lockUser.id,
|
||||
username: lockUser.username,
|
||||
full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success(reply, { ...order, locked_by: lockedBy });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/lock — acquire edit lock (mirrors offers)
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/lock",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const order = await prisma.issued_orders.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
|
||||
// Check if locked by someone else and lock is fresh
|
||||
if (order.locked_by && order.locked_at) {
|
||||
const lockAge = Date.now() - new Date(order.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
order.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: order.locked_by },
|
||||
select: { first_name: true, last_name: true },
|
||||
});
|
||||
return error(
|
||||
reply,
|
||||
`Objednávku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
|
||||
423,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.issued_orders.update({
|
||||
where: { id },
|
||||
data: { locked_by: request.authData!.userId, locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, "Zámek nastaven");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/heartbeat — keep lock alive
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/heartbeat",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.issued_orders.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/issued-orders/:id/unlock — release lock
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/unlock",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.issued_orders.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_by: null, locked_at: null },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/:id/file — serve the archived PDF from the
|
||||
// NAS; on an archive miss, fall back to a LIVE render from current data,
|
||||
// re-archive the fresh PDF, and serve it (archived-file model with live
|
||||
// fallback — same model as offers' /:id/file).
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/file",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const order = await prisma.issued_orders.findUnique({ where: { id } });
|
||||
// A draft has no po_number → nothing was ever archived; 404 like offers.
|
||||
if (!order?.po_number) return error(reply, "Objednávka nenalezena", 404);
|
||||
|
||||
const fileName = `Objednavka-${order.po_number}.pdf`;
|
||||
|
||||
// Archive hit: the by-number sweep finds the PDF in whichever
|
||||
// Vydané/YYYY/MM folder it was saved to (order_date can move after
|
||||
// archiving, so a fixed expected path would miss).
|
||||
const archived = nasOrdersManager.readIssuedByNumber(order.po_number);
|
||||
if (archived) {
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", contentDisposition("inline", fileName))
|
||||
.send(archived.data);
|
||||
}
|
||||
|
||||
// Archive miss → live render (same data path as issued-orders-pdf),
|
||||
// then archive the fresh PDF so the next request is a plain hit.
|
||||
try {
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const sections = await prisma.issued_order_sections.findMany({
|
||||
where: { issued_order_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const supplier = order.supplier_id
|
||||
? ((await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: order.supplier_id },
|
||||
})) as Record<string, unknown> | null)
|
||||
: null;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const lang =
|
||||
order.language === "en" ? ("en" as const) : ("cs" as const);
|
||||
const issuer = {
|
||||
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
|
||||
};
|
||||
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
supplier,
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
sections,
|
||||
);
|
||||
const pdf = await htmlToPdf(html, {
|
||||
headerTemplate: buildIssuedOrderPdfHeader(
|
||||
lang,
|
||||
logoDataUriFromSettings(settings),
|
||||
order.po_number || "",
|
||||
),
|
||||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||
});
|
||||
|
||||
if (nasOrdersManager.isConfigured()) {
|
||||
const baseDate = order.order_date
|
||||
? new Date(order.order_date)
|
||||
: new Date();
|
||||
nasOrdersManager.cleanIssued(order.po_number);
|
||||
const saved = nasOrdersManager.saveIssuedPdf(
|
||||
order.po_number,
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth() + 1,
|
||||
pdf,
|
||||
);
|
||||
if (!saved) {
|
||||
request.log.error(
|
||||
`Archivace PDF objednávky ${order.po_number} na NAS při /file fallbacku selhala`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", contentDisposition("inline", fileName))
|
||||
.send(pdf);
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
err,
|
||||
"Issued order /file live-render fallback failed",
|
||||
);
|
||||
return error(reply, "Chyba při generování PDF", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -95,6 +361,13 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const order = await createIssuedOrder(parsed.data);
|
||||
if ("error" in order) {
|
||||
if (order.error === "supplier_not_found")
|
||||
return error(reply, "Dodavatel nenalezen", 400);
|
||||
if (order.error === "po_number_taken")
|
||||
return error(reply, "Číslo objednávky je již použito", 409);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
@@ -125,6 +398,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Objednávka nenalezena", 404);
|
||||
if (result.error === "not_editable")
|
||||
return error(reply, "Objednávku v tomto stavu nelze upravovat", 400);
|
||||
if (result.error === "supplier_not_found")
|
||||
return error(reply, "Dodavatel nenalezen", 400);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
@@ -139,9 +416,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
action: "update",
|
||||
entityType: "issued_order",
|
||||
entityId: id,
|
||||
description: `Upravena vydaná objednávka ${result.po_number}`,
|
||||
description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`,
|
||||
oldValues: result.oldValues,
|
||||
newValues: result.newValues,
|
||||
});
|
||||
return success(reply, { id }, 200, "Objednávka byla aktualizována");
|
||||
// po_number in the response so a draft->sent finalize immediately shows
|
||||
// the assigned number (POST already returns it).
|
||||
return success(
|
||||
reply,
|
||||
{ id, po_number: result.po_number },
|
||||
200,
|
||||
"Objednávka byla aktualizována",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -160,7 +446,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
action: "delete",
|
||||
entityType: "issued_order",
|
||||
entityId: id,
|
||||
description: `Smazána vydaná objednávka ${existing.po_number}`,
|
||||
description: `Smazána vydaná objednávka ${existing.po_number ?? "koncept"}`,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
});
|
||||
return success(reply, null, 200, "Objednávka smazána");
|
||||
},
|
||||
|
||||
@@ -1,102 +1,24 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import type { Prisma, company_settings } from "@prisma/client";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
/** Format number with comma decimal separator and non-breaking space thousands separator */
|
||||
function formatNum(n: number, decimals: number): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currency: string): string {
|
||||
const n = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case "EUR":
|
||||
return `${formatNum(n, 2)} \u20AC`;
|
||||
case "USD":
|
||||
return `$${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
case "CZK":
|
||||
return `${formatNum(n, 2)} K\u010D`;
|
||||
case "GBP":
|
||||
return `\u00A3${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
default:
|
||||
return `${formatNum(n, 2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
const allowedTags =
|
||||
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
|
||||
let s = html;
|
||||
// Remove dangerous tags with content
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
// Strip event handlers
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
// Strip javascript:/data:/vbscript: in href and src (defense-in-depth,
|
||||
// matching the invoices/orders copies — DOMPurify runs first).
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -203,44 +125,31 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||
total: { EN: "Total", CZ: "Celkem" },
|
||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||
vat: { EN: "VAT", CZ: "DPH" },
|
||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
||||
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||
page: { EN: "Page", CZ: "Strana" },
|
||||
of: { EN: "of", CZ: "z" },
|
||||
};
|
||||
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
/** Quotation row with the relations the PDF template renders. */
|
||||
export type OfferForPdf = Prisma.quotationsGetPayload<{
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
customers: true;
|
||||
quotation_items: true;
|
||||
scope_sections: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
if (!quotation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
/**
|
||||
* Build the full offer PDF/preview HTML. Extracted from the route handler so
|
||||
* the offers /:id/file live-render fallback (quotations.ts) can reuse the
|
||||
* exact same template without going through HTTP.
|
||||
*/
|
||||
export function renderOfferHtml(
|
||||
quotation: OfferForPdf,
|
||||
settings: company_settings | null,
|
||||
): string {
|
||||
const isCzech = (quotation.language ?? "EN") !== "EN";
|
||||
const langKey = isCzech ? "CZ" : "EN";
|
||||
const currency = quotation.currency || "EUR";
|
||||
@@ -256,25 +165,33 @@ export default async function offersPdfRoutes(
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Offers are NOT tax documents — the total is NET only (no VAT math).
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
// Visibility uses the SAME per-language title rule as the render loop plus
|
||||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||||
// — otherwise a section with only the other language's title would count as
|
||||
// content and produce a scope page holding nothing but the header. Mirrors
|
||||
// issued-orders-pdf.
|
||||
const stripSectionTags = (html: string | null | undefined): string =>
|
||||
(html || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
const resolveSectionTitle = (s: {
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
}): string =>
|
||||
isCzech && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || "";
|
||||
const visibleSections = quotation.scope_sections.filter(
|
||||
(s) => resolveSectionTitle(s).trim() || stripSectionTags(s.content),
|
||||
);
|
||||
const hasScopeContent = visibleSections.length > 0;
|
||||
|
||||
const cust = buildAddressLines(
|
||||
quotation.customers as unknown as Record<string, unknown>,
|
||||
@@ -295,45 +212,30 @@ export default async function offersPdfRoutes(
|
||||
.join("");
|
||||
|
||||
// Indentation CSS for Quill
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const qty = Number(item.quantity) || 0;
|
||||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
||||
// Integers render without decimals; fractional quantities keep 2 decimals
|
||||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="value">${formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||
// No Mezisoučet/VAT rows — they would only duplicate the net total.
|
||||
const totalsHtml = `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
|
||||
<span class="value">${formatCurrency(total, currency)}</span>
|
||||
</div>`;
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
|
||||
@@ -351,15 +253,11 @@ export default async function offersPdfRoutes(
|
||||
</div>
|
||||
<hr class="separator" />`;
|
||||
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title =
|
||||
isCzech && (section.title_cz || "").trim()
|
||||
? section.title_cz
|
||||
: section.title || "";
|
||||
for (const section of visibleSections) {
|
||||
const title = resolveSectionTitle(section);
|
||||
const content = (section.content || "").trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title)
|
||||
if (title.trim())
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
@@ -749,6 +647,40 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!quotation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Nabídka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const html = renderOfferHtml(quotation, settings);
|
||||
|
||||
const saveMode = query.save === "1";
|
||||
|
||||
// Save PDF to NAS
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, error } from "../../utils/response";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
} from "../../utils/pdf-shared";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { z } from "zod";
|
||||
import createDOMPurify from "dompurify";
|
||||
@@ -20,69 +26,18 @@ const OrderPdfItemSchema = z.object({
|
||||
unit: z.string().max(255),
|
||||
unit_price: z.number().min(0).finite(),
|
||||
is_included_in_total: z.boolean().optional(),
|
||||
vat_rate: z.number().min(0).max(100).finite(),
|
||||
});
|
||||
|
||||
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
||||
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
|
||||
// handler no longer reads them as untyped passthrough keys.
|
||||
// `items` is strictly validated; `lang` is typed explicitly so the handler no
|
||||
// longer reads it as an untyped passthrough key.
|
||||
const OrderPdfBodySchema = z.looseObject({
|
||||
items: z.array(OrderPdfItemSchema).optional(),
|
||||
lang: z.string().max(10).optional(),
|
||||
applyVat: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
@@ -193,13 +148,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem",
|
||||
total_no_vat: "Celkem bez DPH",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
issued_by: "Vystavil:",
|
||||
@@ -221,13 +171,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total",
|
||||
total_no_vat: "Total excl. VAT",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
issued_by: "Issued by:",
|
||||
@@ -238,44 +183,40 @@ const translations: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
/* ── Template ────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
export interface OrderConfirmationPdfItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
interface OrderConfirmationPdfData {
|
||||
order_number: string | null;
|
||||
customer_order_number: string | null;
|
||||
created_at: Date | string | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
// Not a real orders column today — read defensively (PHP-era data carried it).
|
||||
payment_method?: string | null;
|
||||
customers?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
|
||||
* are NET only: no VAT columns or VAT summary; the grand total is labeled
|
||||
* "Celkem bez DPH". Exported for tests.
|
||||
*/
|
||||
export function renderOrderConfirmationHtml(
|
||||
order: OrderConfirmationPdfData,
|
||||
items: OrderConfirmationPdfItem[],
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: "cs" | "en",
|
||||
userName: string,
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
@@ -289,76 +230,13 @@ export default async function ordersPdfRoutes(
|
||||
}
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_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: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
// NET-only total over the included lines — no VAT math anywhere.
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
if (item.is_included_in_total) total += item.quantity * item.unit_price;
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
total = Math.round(total * 100) / 100;
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
@@ -380,41 +258,22 @@ export default async function ordersPdfRoutes(
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(item.unit_price)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod =
|
||||
String((order as Record<string, unknown>).payment_method || "") ||
|
||||
String(order.payment_method || "") ||
|
||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
@@ -427,15 +286,9 @@ export default async function ordersPdfRoutes(
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
const indentCSS = buildQuillIndentCss();
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -683,47 +536,6 @@ export default async function ordersPdfRoutes(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* DPH rekapitulace + QR */
|
||||
.recap-section {
|
||||
display: flex;
|
||||
gap: 5mm;
|
||||
align-items: flex-start;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.recap-section .qr {
|
||||
flex-shrink: 0;
|
||||
width: 28mm;
|
||||
}
|
||||
.recap-section .qr img,
|
||||
.recap-section .qr svg { width: 28mm; height: 28mm; }
|
||||
|
||||
.recap-section table {
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
flex: 1;
|
||||
}
|
||||
.recap-section table th {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #ccc;
|
||||
}
|
||||
.recap-section table td {
|
||||
padding: 3px 6px;
|
||||
text-align: right;
|
||||
border-bottom: 0.5pt solid #eee;
|
||||
}
|
||||
.recap-section table td.center { text-align: center; }
|
||||
.recap-section table td.cnb-rate {
|
||||
font-size: 8pt;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
border-bottom: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Prevzal / razitko */
|
||||
.footer-row {
|
||||
display: flex;
|
||||
@@ -845,13 +657,10 @@ ${indentCSS}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -859,18 +668,12 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(t.total)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
</div>
|
||||
@@ -897,9 +700,96 @@ ${indentCSS}
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function ordersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||
// a live preview of unsaved edits on the detail page) OR from the
|
||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||
// stored order is an editing action, so the custom-items path requires
|
||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||
// the STORED order items instead — preventing a `orders.view` holder
|
||||
// from producing an "official" confirmation with invented figures.
|
||||
const authData = request.authData;
|
||||
const canUseCustomItems =
|
||||
authData?.roleName === "admin" ||
|
||||
!!authData?.permissions.includes("orders.edit");
|
||||
const customItemsRaw =
|
||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||
|
||||
let items: OrderConfirmationPdfItem[];
|
||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_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,
|
||||
}));
|
||||
}
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
items,
|
||||
settings,
|
||||
lang,
|
||||
userName,
|
||||
);
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { config } from "../../config/env";
|
||||
import {
|
||||
@@ -110,10 +111,12 @@ export default async function ordersRoutes(
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("inline", attachment.filename),
|
||||
)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
CreateQuotationSchema,
|
||||
@@ -21,8 +22,13 @@ import {
|
||||
getNextOfferNumber,
|
||||
} from "../../services/offers.service";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { renderOfferHtml } from "./offers-pdf";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat
|
||||
// 3× the client's 10s heartbeat: a single missed beat (background-tab
|
||||
// throttling, transient network) must not silently hand the lock to a second
|
||||
// editor. Keep in sync with issued-orders.ts and useDocumentLock.
|
||||
const LOCK_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
export default async function quotationsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -53,9 +59,10 @@ export default async function quotationsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the
|
||||
// WHOLE filtered set (not one page). Offers have NO month/year filter; the
|
||||
// list filters by search + status + customer_id, so the totals use the same.
|
||||
// GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
|
||||
// filtered set (not one page). Offers are not tax documents — no VAT
|
||||
// anywhere. Offers have NO month/year filter; the list filters by
|
||||
// search + status + customer_id, so the totals use the same.
|
||||
// Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
@@ -117,8 +124,19 @@ export default async function quotationsRoutes(
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await invalidateOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
const result = await invalidateOffer(id);
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||
400,
|
||||
);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
const existing = result.data;
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -126,7 +144,9 @@ export default async function quotationsRoutes(
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Zneplatněna nabídka ${existing.quotation_number}`,
|
||||
description: `Zneplatněna nabídka ${existing.quotation_number ?? "koncept"}`,
|
||||
oldValues: { status: existing.status },
|
||||
newValues: { status: "invalidated" },
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka zneplatněna");
|
||||
},
|
||||
@@ -262,8 +282,13 @@ export default async function quotationsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const quotation = await createOffer(parsed.data);
|
||||
if ("error" in quotation)
|
||||
return error(reply, quotation.error!, quotation.status!);
|
||||
if ("error" in quotation) {
|
||||
if (quotation.error === "customer_not_found")
|
||||
return error(reply, "Zákazník nenalezen", 400);
|
||||
if (quotation.error === "number_taken")
|
||||
return error(reply, "Číslo nabídky je již použito", 409);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -295,13 +320,17 @@ export default async function quotationsRoutes(
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "invalidated")
|
||||
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
|
||||
if (result.error === "not_editable")
|
||||
return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
result.error || "Neznámá chyba",
|
||||
"status" in result ? result.status : 400,
|
||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||
400,
|
||||
);
|
||||
if (result.error === "customer_not_found")
|
||||
return error(reply, "Zákazník nenalezen", 400);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
|
||||
// Keep lock — user stays on the page after save
|
||||
@@ -312,7 +341,9 @@ export default async function quotationsRoutes(
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Upravena nabídka ${result.quotation_number}`,
|
||||
description: `Upravena nabídka ${result.quotation_number ?? "koncept"}`,
|
||||
oldValues: result.oldValues,
|
||||
newValues: result.newValues,
|
||||
});
|
||||
return success(reply, { id }, 200, "Nabídka byla uložena");
|
||||
},
|
||||
@@ -325,25 +356,23 @@ export default async function quotationsRoutes(
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await deleteOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
const result = await deleteOffer(id);
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "linked")
|
||||
return error(
|
||||
reply,
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
409,
|
||||
);
|
||||
return error(reply, "Neznámá chyba", 500);
|
||||
}
|
||||
const existing = result.data;
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.quotation_number && existing.created_at) {
|
||||
const yr = new Date(existing.created_at).getFullYear();
|
||||
try {
|
||||
await nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
} catch (e) {
|
||||
// Non-fatal: NAS delete may fail if the file does not exist — but
|
||||
// never swallow silently (project never-swallow rule).
|
||||
request.log.warn(
|
||||
e,
|
||||
`Smazání PDF nabídky ${existing.quotation_number} z NAS selhalo`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// NAS PDF cleanup is owned by deleteOffer (single owner) — the route
|
||||
// used to repeat it here, which always failed the second time and logged
|
||||
// a spurious error.
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -351,7 +380,8 @@ export default async function quotationsRoutes(
|
||||
action: "delete",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Smazána nabídka ${existing.quotation_number}`,
|
||||
description: `Smazána nabídka ${existing.quotation_number ?? "koncept"}`,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka smazána");
|
||||
},
|
||||
@@ -379,15 +409,71 @@ export default async function quotationsRoutes(
|
||||
year,
|
||||
);
|
||||
const file = nasOffersManager.readOfferPdf(relPath);
|
||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||
if (file) {
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
// Quotation numbers contain "/" segments — not header- or
|
||||
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
|
||||
contentDisposition(
|
||||
"inline",
|
||||
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
|
||||
),
|
||||
)
|
||||
.send(file.data);
|
||||
}
|
||||
|
||||
// Archive miss → live-render fallback (same model as issued orders'
|
||||
// /:id/file): rebuild the PDF from current data with the exact PDF-route
|
||||
// template, re-archive it so the next request is a plain hit, and serve
|
||||
// the fresh bytes with identical response semantics.
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const html = renderOfferHtml(quotation, settings);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
|
||||
if (nasOffersManager.isConfigured()) {
|
||||
// saveOfferPdf logs internally and returns null on failure —
|
||||
// archiving is best-effort here, serving the PDF must not fail.
|
||||
const saved = nasOffersManager.saveOfferPdf(
|
||||
offer.quotation_number,
|
||||
year,
|
||||
pdfBuffer,
|
||||
);
|
||||
if (!saved) {
|
||||
request.log.error(
|
||||
`Archivace PDF nabídky ${offer.quotation_number} na NAS při /file fallbacku selhala`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${offer.quotation_number}.pdf"`,
|
||||
// Quotation numbers contain "/" segments — not header- or
|
||||
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
|
||||
contentDisposition(
|
||||
"inline",
|
||||
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
|
||||
),
|
||||
)
|
||||
.send(file.data);
|
||||
.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
request.log.error(err, "Offer /file live-render fallback failed");
|
||||
return error(reply, "Chyba při generování PDF", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "../../schemas/received-invoices.schema";
|
||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||
import { toCzk } from "../../services/exchange-rates";
|
||||
import { utcMidnightOfLocalDay } from "../../utils/date";
|
||||
import path from "path";
|
||||
|
||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||
@@ -294,10 +296,14 @@ export default async function receivedInvoicesRoutes(
|
||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||
|
||||
const mime = invoice.file_mime || "application/pdf";
|
||||
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
|
||||
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
|
||||
// window for viewing, not as a forced download.
|
||||
return reply
|
||||
.type(mime)
|
||||
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("inline", invoice.file_name),
|
||||
)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
@@ -556,14 +562,17 @@ export default async function receivedInvoicesRoutes(
|
||||
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
||||
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
||||
|
||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
||||
// Auto-set paid_date when status transitions to paid (matching PHP).
|
||||
// paid_date is @db.Date (truncated to the UTC date part) —
|
||||
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
|
||||
// 00:00–02:00 Prague window (a bare new Date() stored yesterday there).
|
||||
const newStatus =
|
||||
body.status !== undefined
|
||||
? String(body.status)
|
||||
: String(existing.status);
|
||||
const paidDate =
|
||||
newStatus === "paid" && String(existing.status) !== "paid"
|
||||
? new Date()
|
||||
? utcMidnightOfLocalDay()
|
||||
: body.paid_date !== undefined
|
||||
? body.paid_date
|
||||
? new Date(String(body.paid_date))
|
||||
|
||||
@@ -264,7 +264,8 @@ export default async function totpRoutes(
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, backup_code: code } = parsed.data;
|
||||
const { login_token, backup_code: code, remember_me } = parsed.data;
|
||||
const rememberMe = remember_me ?? false;
|
||||
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
@@ -393,14 +394,18 @@ export default async function totpRoutes(
|
||||
.update(refreshTokenRaw)
|
||||
.digest("hex");
|
||||
|
||||
// Mirror /login/totp: honor remember_me so a backup-code login keeps
|
||||
// the same session longevity the user asked for at the password step.
|
||||
const refreshExpiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(
|
||||
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000,
|
||||
),
|
||||
remember_me: false,
|
||||
expires_at: new Date(Date.now() + refreshExpiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
@@ -411,7 +416,7 @@ export default async function totpRoutes(
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
path: "/api/admin",
|
||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||
maxAge: refreshExpiresIn,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -6,34 +6,39 @@ import { success, paginated, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
|
||||
import { AuthData } from "../../types";
|
||||
|
||||
export default async function tripsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"trips.manage",
|
||||
"trips.record",
|
||||
"trips.history",
|
||||
),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
const authData = request.authData!;
|
||||
/**
|
||||
* Shared filter/scoping logic for the trip list AND the stats endpoint — the
|
||||
* stats MUST honor exactly the same filters as the list so the cards always
|
||||
* describe the rows the list is paging through.
|
||||
*
|
||||
* Scoping: admins / trips.manage holders see all trips (optionally filtered
|
||||
* by user_id); everyone else is hard-scoped to their own trips.
|
||||
*/
|
||||
function buildTripsWhere(
|
||||
query: Record<string, unknown>,
|
||||
authData: AuthData,
|
||||
): Record<string, unknown> {
|
||||
// Admin role bypasses permission checks entirely (requireAnyPermission
|
||||
// lets it through with an empty permissions list), so treat the admin
|
||||
// role as a manager for scoping purposes too.
|
||||
const isAdmin =
|
||||
const isManager =
|
||||
authData.roleName === "admin" ||
|
||||
authData.permissions.includes("trips.manage");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isAdmin) where.user_id = authData.userId;
|
||||
else if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
|
||||
if (!isManager) where.user_id = authData.userId;
|
||||
else if (query.user_id) {
|
||||
// Non-numeric filter values are ignored (like an invalid month below) —
|
||||
// NaN in a Prisma where clause would throw and surface as a 500.
|
||||
const uid = Number(query.user_id);
|
||||
if (Number.isFinite(uid)) where.user_id = uid;
|
||||
}
|
||||
if (query.vehicle_id) {
|
||||
const vid = Number(query.vehicle_id);
|
||||
if (Number.isFinite(vid)) where.vehicle_id = vid;
|
||||
}
|
||||
|
||||
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
|
||||
if (query.month) {
|
||||
@@ -65,12 +70,61 @@ export default async function tripsRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a vehicle's actual_km as MAX(end_km) over its trips, falling
|
||||
* back to initial_km when the vehicle has no trips. Shared by the POST,
|
||||
* PUT and DELETE handlers so the odometer logic can't drift.
|
||||
*/
|
||||
async function recomputeVehicleActualKm(vehicleId: number): Promise<void> {
|
||||
const [maxTrip, vehicle] = await Promise.all([
|
||||
prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
}),
|
||||
prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
}),
|
||||
]);
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: {
|
||||
actual_km: maxTrip
|
||||
? Number(maxTrip.end_km)
|
||||
: Number(vehicle?.initial_km ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function tripsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"trips.manage",
|
||||
"trips.record",
|
||||
"trips.history",
|
||||
),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
const [trips, total] = await Promise.all([
|
||||
prisma.trips.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { trip_date: order },
|
||||
// trip_date is date-only — same-day rows need the id tiebreak for
|
||||
// deterministic pagination.
|
||||
orderBy: [{ trip_date: order }, { id: order }],
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
@@ -83,6 +137,60 @@ export default async function tripsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/trips/stats — count/km totals over the WHOLE filtered set
|
||||
// (not one page). Honors exactly the same filters + user scoping as the
|
||||
// list (month/year, vehicle_id, user_id for managers) so the stat cards
|
||||
// can't drift from the rows the list is paging through.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"trips.manage",
|
||||
"trips.record",
|
||||
"trips.history",
|
||||
),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
// Legacy PHP-era rows can carry a stored `distance` that differs from
|
||||
// end_km - start_km, and every row renderer prefers it
|
||||
// (`distance ?? end_km - start_km`) — the totals must apply the same
|
||||
// coalesce or the stat cards would disagree with the visible rows.
|
||||
// That rules out a plain groupBy aggregate.
|
||||
const rows = await prisma.trips.findMany({
|
||||
where,
|
||||
select: {
|
||||
distance: true,
|
||||
start_km: true,
|
||||
end_km: true,
|
||||
is_business: true,
|
||||
},
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of rows) {
|
||||
const km =
|
||||
t.distance != null
|
||||
? Number(t.distance)
|
||||
: Number(t.end_km) - Number(t.start_km);
|
||||
count += 1;
|
||||
if (t.is_business) businessKm += km;
|
||||
else privateKm += km;
|
||||
}
|
||||
|
||||
return success(reply, {
|
||||
count,
|
||||
total_km: businessKm + privateKm,
|
||||
business_km: businessKm,
|
||||
private_km: privateKm,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/trips/users — users with trips.record permission
|
||||
fastify.get(
|
||||
"/users",
|
||||
@@ -121,28 +229,11 @@ export default async function tripsRoutes(
|
||||
{ preHandler: requirePermission("trips.manage") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const filterVehicleId = query.vehicle_id
|
||||
? Number(query.vehicle_id)
|
||||
: null;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||
if (query.month && query.year) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const yr = Number(query.year);
|
||||
const mo = Number(query.month);
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
// Same shared filter source as the list + stats endpoints (incl. the
|
||||
// month 1-12 validation). /print is manager-only (trips.manage guard),
|
||||
// so buildTripsWhere always takes its manager branch here — user_id /
|
||||
// vehicle_id / month filters behave exactly as before.
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
const trips = await prisma.trips.findMany({
|
||||
where,
|
||||
@@ -150,7 +241,8 @@ export default async function tripsRoutes(
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
},
|
||||
orderBy: { trip_date: "asc" },
|
||||
// trip_date is date-only — id tiebreak keeps same-day rows stable
|
||||
orderBy: [{ trip_date: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
const vehicles = await prisma.vehicles.findMany({
|
||||
@@ -166,7 +258,13 @@ export default async function tripsRoutes(
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of trips) {
|
||||
const dist = Number(t.end_km) - Number(t.start_km);
|
||||
// Same coalesce as the row renderers and /stats: legacy rows may
|
||||
// store a `distance` differing from end_km - start_km, and the print
|
||||
// footer must match the sum of the printed rows.
|
||||
const dist =
|
||||
t.distance != null
|
||||
? Number(t.distance)
|
||||
: Number(t.end_km) - Number(t.start_km);
|
||||
totalKm += dist;
|
||||
if (t.is_business) businessKm += dist;
|
||||
else privateKm += dist;
|
||||
@@ -251,21 +349,13 @@ export default async function tripsRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
// Recompute actual_km from MAX(end_km) across all trips (same as
|
||||
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
|
||||
// regress the odometer when a back-dated trip with a lower end_km is
|
||||
// entered after a later trip with a higher reading.
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: Number(maxTrip.end_km) },
|
||||
});
|
||||
}
|
||||
// Recompute actual_km from MAX(end_km) across all trips (same helper
|
||||
// as PUT/DELETE). Setting it unconditionally to this trip's end_km
|
||||
// would regress the odometer when a back-dated trip with a lower
|
||||
// end_km is entered after a later trip with a higher reading. (The
|
||||
// helper's initial_km fallback is unreachable here — the just-created
|
||||
// trip guarantees at least one row.)
|
||||
await recomputeVehicleActualKm(vehicleId);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -312,6 +402,19 @@ export default async function tripsRoutes(
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.vehicle_id !== undefined) {
|
||||
const targetVehicleId = Number(body.vehicle_id);
|
||||
if (targetVehicleId !== existing.vehicle_id) {
|
||||
// Schema only guarantees a positive integer — without this check a
|
||||
// nonexistent id would hit the FK as P2003 and surface as a 500.
|
||||
const vehicleExists = await prisma.vehicles.findUnique({
|
||||
where: { id: targetVehicleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!vehicleExists) return error(reply, "Vozidlo nenalezeno", 400);
|
||||
}
|
||||
data.vehicle_id = targetVehicleId;
|
||||
}
|
||||
if (body.trip_date !== undefined)
|
||||
data.trip_date = new Date(String(body.trip_date));
|
||||
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
||||
@@ -325,20 +428,19 @@ export default async function tripsRoutes(
|
||||
|
||||
await prisma.trips.update({ where: { id }, data });
|
||||
|
||||
// Update vehicle actual_km if end_km changed
|
||||
if (body.end_km !== undefined) {
|
||||
const vehicleId = existing.vehicle_id;
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: Number(maxTrip.end_km) },
|
||||
});
|
||||
// Recompute odometers when the km reading or the vehicle changed: the
|
||||
// trip's (possibly new) vehicle, and — when the trip moved between
|
||||
// vehicles — the old one too, which may have lost its MAX(end_km) row.
|
||||
const newVehicleId =
|
||||
body.vehicle_id !== undefined
|
||||
? Number(body.vehicle_id)
|
||||
: existing.vehicle_id;
|
||||
const vehicleChanged = newVehicleId !== existing.vehicle_id;
|
||||
if (body.end_km !== undefined || vehicleChanged) {
|
||||
await recomputeVehicleActualKm(newVehicleId);
|
||||
}
|
||||
if (vehicleChanged) {
|
||||
await recomputeVehicleActualKm(existing.vehicle_id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
@@ -348,6 +450,8 @@ export default async function tripsRoutes(
|
||||
entityType: "trip",
|
||||
entityId: id,
|
||||
description: `Upravena jízda`,
|
||||
oldValues: existing,
|
||||
newValues: data,
|
||||
});
|
||||
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
||||
},
|
||||
@@ -372,24 +476,9 @@ export default async function tripsRoutes(
|
||||
const vehicleId = existing.vehicle_id;
|
||||
await prisma.trips.delete({ where: { id } });
|
||||
|
||||
// Recalculate vehicle actual_km after deletion
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
const vehicle = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
});
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: {
|
||||
actual_km: maxTrip
|
||||
? Number(maxTrip.end_km)
|
||||
: (vehicle?.initial_km ?? 0),
|
||||
},
|
||||
});
|
||||
// Recalculate vehicle actual_km after deletion (falls back to
|
||||
// initial_km when this was the vehicle's last trip).
|
||||
await recomputeVehicleActualKm(vehicleId);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -5,7 +5,12 @@ import { config } from "../../config/env";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import {
|
||||
encodeCustomFields,
|
||||
decodeCustomFields,
|
||||
} from "../../utils/custom-fields";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||
import {
|
||||
@@ -225,7 +230,7 @@ export default async function warehouseRoutes(
|
||||
// SUPPLIERS
|
||||
// =============================================================
|
||||
|
||||
// GET /suppliers — paginated list, search by name/ico/contact_person
|
||||
// GET /suppliers — paginated list, search by name/ico/city
|
||||
fastify.get(
|
||||
"/suppliers",
|
||||
{ preHandler: requirePermission("warehouse.manage") },
|
||||
@@ -239,7 +244,7 @@ export default async function warehouseRoutes(
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ ico: { contains: search } },
|
||||
{ contact_person: { contains: search } },
|
||||
{ city: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -253,9 +258,16 @@ export default async function warehouseRoutes(
|
||||
prisma.sklad_suppliers.count({ where }),
|
||||
]);
|
||||
|
||||
// Decode the custom_fields blob into the array shape the modal edits
|
||||
// (same model as customers).
|
||||
const enriched = suppliers.map((s) => ({
|
||||
...s,
|
||||
custom_fields: decodeCustomFields(s.custom_fields).custom_fields,
|
||||
}));
|
||||
|
||||
return paginated(
|
||||
reply,
|
||||
suppliers,
|
||||
enriched,
|
||||
buildPaginationMeta(total, page, limit),
|
||||
);
|
||||
},
|
||||
@@ -274,7 +286,10 @@ export default async function warehouseRoutes(
|
||||
});
|
||||
if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
|
||||
|
||||
return success(reply, supplier);
|
||||
return success(reply, {
|
||||
...supplier,
|
||||
custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -292,11 +307,12 @@ export default async function warehouseRoutes(
|
||||
name: body.name,
|
||||
ico: body.ico ?? null,
|
||||
dic: body.dic ?? null,
|
||||
contact_person: body.contact_person ?? null,
|
||||
email: body.email ?? null,
|
||||
phone: body.phone ?? null,
|
||||
address: body.address ?? null,
|
||||
notes: body.notes ?? null,
|
||||
street: body.street ?? null,
|
||||
city: body.city ?? null,
|
||||
postal_code: body.postal_code ?? null,
|
||||
country: body.country ?? null,
|
||||
// Suppliers have no PDF field-order picker — order is always [].
|
||||
custom_fields: encodeCustomFields(body.custom_fields, []),
|
||||
is_active: body.is_active ?? true,
|
||||
},
|
||||
});
|
||||
@@ -311,7 +327,7 @@ export default async function warehouseRoutes(
|
||||
newValues: {
|
||||
name: supplier.name,
|
||||
ico: supplier.ico,
|
||||
contact_person: supplier.contact_person,
|
||||
city: supplier.city,
|
||||
is_active: supplier.is_active,
|
||||
},
|
||||
});
|
||||
@@ -341,12 +357,13 @@ export default async function warehouseRoutes(
|
||||
if (body.name !== undefined) updateData.name = body.name;
|
||||
if (body.ico !== undefined) updateData.ico = body.ico;
|
||||
if (body.dic !== undefined) updateData.dic = body.dic;
|
||||
if (body.contact_person !== undefined)
|
||||
updateData.contact_person = body.contact_person;
|
||||
if (body.email !== undefined) updateData.email = body.email;
|
||||
if (body.phone !== undefined) updateData.phone = body.phone;
|
||||
if (body.address !== undefined) updateData.address = body.address;
|
||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||
if (body.street !== undefined) updateData.street = body.street;
|
||||
if (body.city !== undefined) updateData.city = body.city;
|
||||
if (body.postal_code !== undefined)
|
||||
updateData.postal_code = body.postal_code;
|
||||
if (body.country !== undefined) updateData.country = body.country;
|
||||
if (body.custom_fields !== undefined)
|
||||
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
|
||||
if (body.is_active !== undefined) updateData.is_active = body.is_active;
|
||||
|
||||
const updated = await prisma.sklad_suppliers.update({
|
||||
@@ -364,13 +381,13 @@ export default async function warehouseRoutes(
|
||||
oldValues: {
|
||||
name: existing.name,
|
||||
ico: existing.ico,
|
||||
contact_person: existing.contact_person,
|
||||
city: existing.city,
|
||||
is_active: existing.is_active,
|
||||
},
|
||||
newValues: {
|
||||
name: updated.name,
|
||||
ico: updated.ico,
|
||||
contact_person: updated.contact_person,
|
||||
city: updated.city,
|
||||
is_active: updated.is_active,
|
||||
},
|
||||
});
|
||||
@@ -1294,6 +1311,41 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /receipts/:id/attachments/:attachmentId — download attachment from NAS
|
||||
fastify.get<{ Params: { id: string; attachmentId: string } }>(
|
||||
"/receipts/:id/attachments/:attachmentId",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachmentId = parseId(request.params.attachmentId, reply);
|
||||
if (attachmentId === null) return;
|
||||
|
||||
const attachment = await prisma.sklad_receipt_attachments.findUnique({
|
||||
where: { id: attachmentId },
|
||||
});
|
||||
// 404 (not 400) when the attachment belongs to a different receipt:
|
||||
// a read endpoint should not confirm that the id exists elsewhere.
|
||||
if (!attachment || attachment.receipt_id !== id) {
|
||||
return error(reply, "Příloha nenalezena", 404);
|
||||
}
|
||||
|
||||
const nasFile = nasInvoicesManager.readReceived(attachment.file_path);
|
||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||
|
||||
const mime = attachment.file_mime || "application/octet-stream";
|
||||
// "attachment": the UI always downloads via blob + a.download, never
|
||||
// renders inline — and attachment is safer with client-controlled mime.
|
||||
return reply
|
||||
.type(mime)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("attachment", attachment.file_name),
|
||||
)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /receipts/:id/attachments — upload attachment
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/receipts/:id/attachments",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
intIdFromForm,
|
||||
isoDateString,
|
||||
nullableDateTimeString,
|
||||
nullableIntIdFromForm,
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
@@ -66,19 +68,28 @@ export const AttendancePunchSchema = z.object({
|
||||
address: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
// arrival/departure/break fields travel as COMBINED local datetimes
|
||||
// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a
|
||||
// date + time input) — the service parses them with `new Date(...)`. They are
|
||||
// NOT bare HH:MM times.
|
||||
export const CreateAttendanceSchema = z.object({
|
||||
user_id: intIdFromForm.optional(),
|
||||
shift_date: z.string(),
|
||||
arrival_time: timeString.nullish(),
|
||||
// Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
|
||||
// duplicate-validation window and the stored @db.Date both depend on it.
|
||||
// isoDateString leniently strips a trailing time component.
|
||||
shift_date: isoDateString,
|
||||
arrival_time: nullableDateTimeString.optional(),
|
||||
arrival_lat: numberFromForm.nullish(),
|
||||
arrival_lng: numberFromForm.nullish(),
|
||||
arrival_accuracy: numberFromForm.nullish(),
|
||||
arrival_address: z.string().nullish(),
|
||||
departure_time: timeString.nullish(),
|
||||
departure_time: nullableDateTimeString.optional(),
|
||||
departure_lat: numberFromForm.nullish(),
|
||||
departure_lng: numberFromForm.nullish(),
|
||||
departure_accuracy: numberFromForm.nullish(),
|
||||
departure_address: z.string().nullish(),
|
||||
break_start: nullableDateTimeString.optional(),
|
||||
break_end: nullableDateTimeString.optional(),
|
||||
notes: z.string().nullish(),
|
||||
project_id: nullableIntIdFromForm.nullish(),
|
||||
leave_type: z
|
||||
@@ -90,10 +101,10 @@ export const CreateAttendanceSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateAttendanceSchema = z.object({
|
||||
arrival_time: timeString.nullish(),
|
||||
departure_time: timeString.nullish(),
|
||||
break_start: timeString.nullish(),
|
||||
break_end: timeString.nullish(),
|
||||
arrival_time: nullableDateTimeString.optional(),
|
||||
departure_time: nullableDateTimeString.optional(),
|
||||
break_start: nullableDateTimeString.optional(),
|
||||
break_end: nullableDateTimeString.optional(),
|
||||
notes: z.string().nullish(),
|
||||
project_id: nullableIntIdFromForm.optional(),
|
||||
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),
|
||||
|
||||
@@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({
|
||||
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
|
||||
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
|
||||
.max(64, "Záložní kód je příliš dlouhý"),
|
||||
remember_me: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const TotpEnableSchema = z.object({
|
||||
|
||||
@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
|
||||
message: "Číslo musí být kladné",
|
||||
});
|
||||
|
||||
/** number|string → non-negative integer (e.g. list positions). */
|
||||
export const nonNegativeIntFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
.transform(coerceNum)
|
||||
.refine((n) => Number.isInteger(n) && n >= 0, {
|
||||
message: "Číslo musí být nezáporné celé číslo",
|
||||
});
|
||||
|
||||
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
|
||||
export const intIdFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
@@ -111,6 +119,40 @@ export const timeString = z.preprocess(
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
|
||||
* attendance forms produce by joining a date input and a time input
|
||||
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
|
||||
* stripped before validating), because the client appends ":00" and an edit
|
||||
* form round-trips a value the `Date.toJSON` override serialised as
|
||||
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
|
||||
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
|
||||
*/
|
||||
export const dateTimeString = z.preprocess(
|
||||
(v) =>
|
||||
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
|
||||
? v.slice(0, 16)
|
||||
: v,
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
|
||||
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
|
||||
* forms historically submitted empty strings for unfilled time fields, and a
|
||||
* hard reject would 400 the whole form — including leave records that have no
|
||||
* times at all). Pair with `.optional()` at the call site for fields that may
|
||||
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
|
||||
*/
|
||||
export const nullableDateTimeString = z.preprocess(
|
||||
(v) => (v === "" || v === null ? null : v),
|
||||
z.union([z.null(), dateTimeString]),
|
||||
);
|
||||
|
||||
/**
|
||||
* An email field that also accepts an empty string (meaning "not set"). Many
|
||||
* settings/supplier forms submit "" for un-filled optional emails; a bare
|
||||
@@ -120,3 +162,17 @@ export const emailOrEmpty = z.union([
|
||||
z.literal(""),
|
||||
z.string().max(255).email("Neplatný email"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shared rich-text document section — offers' scope_sections and issued
|
||||
* orders' issued_order_sections are 1:1 mirror tables, so both schemas use
|
||||
* this. Limits are DB-aligned: title/title_cz are VarChar(500) (the old
|
||||
* offers-local copy under-shot at 255), content is Text with the app-level
|
||||
* 8000 cap used by the other rich-text fields.
|
||||
*/
|
||||
export const DocumentSectionSchema = z.object({
|
||||
title: z.string().max(500).nullish(),
|
||||
title_cz: z.string().max(500).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
const InvoiceItemSchema = z.object({
|
||||
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
// `notes` was dropped (printed-notes block removed — free-form PDF content
|
||||
// lives in the sections); legacy clients still sending it are silently
|
||||
// stripped by z.object. internal_notes is never printed.
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
|
||||
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateInvoiceSchema = z.object({
|
||||
@@ -54,16 +60,17 @@ export const UpdateInvoiceSchema = z.object({
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
tax_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
export const IssuedOrderItemSchema = z.object({
|
||||
@@ -14,7 +13,6 @@ export const IssuedOrderItemSchema = z.object({
|
||||
quantity: positiveNumberFromForm.optional(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
position: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
@@ -28,21 +26,26 @@ const ISSUED_ORDER_STATUSES = [
|
||||
|
||||
export const CreateIssuedOrderSchema = z.object({
|
||||
po_number: z.string().max(50).nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
supplier_id: nullableIntIdFromForm.nullish(),
|
||||
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
||||
currency: z.string().max(10).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
exchange_rate: nonNegativeNumberFromForm.optional(),
|
||||
order_date: isoDateString.nullish(),
|
||||
delivery_date: isoDateString.nullish(),
|
||||
language: z.string().max(5).optional(),
|
||||
delivery_terms: z.string().max(500).nullish(),
|
||||
payment_terms: z.string().max(500).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
notes: z.string().nullish(),
|
||||
// Editable heading above the PDF items table; empty/null falls back to the
|
||||
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||
order_text: z.string().max(500).nullish(),
|
||||
internal_notes: z.string().nullish(),
|
||||
items: z.array(IssuedOrderItemSchema).optional(),
|
||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial();
|
||||
// Update = partial create MINUS the number: PO numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service (which never reads it on update anyway).
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial().omit({
|
||||
po_number: true,
|
||||
});
|
||||
|
||||
@@ -1,59 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
nonNegativeIntFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
// Limits are DB-aligned: description VarChar(500), unit VarChar(20);
|
||||
// item_description is Text with the app-level 8000 cap used elsewhere.
|
||||
const QuotationItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
// Int column — a float position would 500 at Prisma.
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
const ScopeSectionSchema = z.object({
|
||||
title: z.string().max(255).nullish(),
|
||||
title_cz: z.string().max(255).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
// Shared with issued orders (scope_sections ≡ issued_order_sections).
|
||||
const ScopeSectionSchema = DocumentSectionSchema;
|
||||
|
||||
// NOTE: no top-level `.default()`s here — `UpdateQuotationSchema` derives from
|
||||
// this schema via `.partial()`, and Zod 4 still applies a field default when
|
||||
// the key is absent, which would silently inject status/currency/language
|
||||
// resets into every update payload. Create-time defaults live in
|
||||
// `createOffer` (service) instead.
|
||||
export const CreateQuotationSchema = z.object({
|
||||
quotation_number: z.string().max(255).nullish(),
|
||||
project_code: z.string().max(255).nullish(),
|
||||
// DB: quotations.quotation_number VarChar(50).
|
||||
quotation_number: z.string().max(50).nullish(),
|
||||
// DB column is VarChar(100) — a longer value would 500 at Prisma.
|
||||
project_code: z.string().max(100).nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
created_at: z.string().max(255).nullish(),
|
||||
valid_until: z.string().max(255).nullish(),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
status: z
|
||||
.enum(["draft", "active", "ordered", "invalidated"])
|
||||
.optional()
|
||||
.default("draft"),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateQuotationSchema = z.object({
|
||||
project_code: z.string().max(255).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
created_at: z.union([z.string().max(255), z.null()]).optional(),
|
||||
valid_until: z.union([z.string().max(255), z.null()]).optional(),
|
||||
created_at: isoDateString.nullish(),
|
||||
valid_until: isoDateString.nullish(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -61,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
// Update = partial create MINUS the number: quotation numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service.
|
||||
export const UpdateQuotationSchema = CreateQuotationSchema.partial().omit({
|
||||
quotation_number: true,
|
||||
});
|
||||
|
||||
export type CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
|
||||
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
intIdFromForm,
|
||||
@@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({
|
||||
.default("prijata"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
items: z.array(OrderItemSchema).optional(),
|
||||
sections: z.array(OrderSectionSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateTripSchema = z.object({
|
||||
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
|
||||
vehicle_id: intIdFromForm.optional(),
|
||||
trip_date: isoDateString.optional(),
|
||||
start_km: nonNegativeNumberFromForm.optional(),
|
||||
end_km: nonNegativeNumberFromForm.optional(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user