Compare commits

...

18 Commits

Author SHA1 Message Date
BOHA
88d5d43448 chore(release): v2.4.8 - deploy-skew self-healing (stale chunk auto-reload + cache headers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:32:49 +02:00
BOHA
4745af3639 fix(spa): self-heal stale clients after deploys (vite:preloadError + cache headers)
After a release the old hashed chunks are deleted, so an open tab from
the previous build failed its next lazy-page import until a manual
refresh. Three-part fix per the Vite deploy guide:

- vite:preloadError listener reloads the page once (10s sessionStorage
  guard against loops) so stale tabs heal themselves
- static cache headers: /assets/* immutable for a year (content-hashed
  names), index.html no-cache so new visits always see fresh chunk refs
- missing /assets/* now 404 instead of getting the SPA index.html
  fallback (HTML-as-JS masked the failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:31:59 +02:00
BOHA
4258699b73 chore(release): v2.4.7 - mobile detail-header overflow fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:22:24 +02:00
BOHA
bfd2c59ad3 fix(ui): detail-form headers no longer overflow on mobile
Two causes: theme h4 had no responsive size (desktop 2.125rem on
phones - now 1.5rem under 600px, applies to all page headlines), and
the Zpet+title header row had no flexWrap so long document titles
pushed past the viewport (issued orders, offers, both invoice views).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:21:29 +02:00
BOHA
0b69dbfde0 chore(release): v2.4.6 - ARES company lookup, suppliers on the customers model (structured address + custom fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:12:57 +02:00
BOHA
db7a5c3d15 feat(suppliers)!: full customers model - structured address + custom fields
The dodavatele modal is now an exact mirror of the customers modal
(minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme,
ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md).

Two migrations on sklad_suppliers:
- structured street/city/postal_code/country replace the free-text
  address blob (best-effort split: street / PSC regex / city)
- custom_fields LONGTEXT replaces contact_person/email/phone/notes;
  existing values are preserved as labeled custom fields

The encode/decode of the custom_fields blob is shared with customers
via the new utils/custom-fields.ts. The PO PDF supplier block renders
the structured address + custom-field lines like the customer block;
the supplier picker/detail selects and types follow. Legacy clients
sending the dropped keys are silently stripped (tested). Suppliers
list shows Ulice/Mesto instead of the dropped contact columns; search
covers name/ico/city.

BREAKING CHANGE: sklad_suppliers.address, contact_person, email,
phone, notes columns dropped (data migrated); deploy must run
prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:22 +02:00
BOHA
f1ce76d21d feat(ares): ARES company lookup with prefill in customer/supplier modals
New /api/admin/ares proxy (browser cannot reach ares.gov.cz - CORS+CSP):
GET /ico/:ico and GET /search?q= map the MFCR REST API to the form
shape (street incl. orientation numbers, "511 01" PSC format, DIC).
Guarded by customers.create/edit + warehouse.manage; token errors map
to Czech messages. Shared AresAdornment button sits in the Nazev and
ICO fields of both modals: ICO mode fills directly, name mode searches
with a result picker. Tested against live ARES (BOHA, ICO 22599851).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:08 +02:00
BOHA
575c07aac8 chore(release): v2.4.5 - invoice Obsah sections + internal notes, unified per-page PDF header/footer, project status tabs+tints, mobile DnD long-press, offer un-stick on order delete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:31:15 +02:00
BOHA
4bf245d35c feat(projects): status filter tabs + per-status row tints on the list
Mirrors the offers page: centered status tabs (Vsechny/Aktivni/
Dokonceny/Zruseny) filtering server-side (?status= already supported),
filtered-vs-empty empty states, and row tints - dokonceny rows get the
info-channel low-alpha wash matching the badge, zruseny rows are faded
like invalidated offers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:30:09 +02:00
BOHA
79d026e756 fix(dnd): mobile item reorder works via long-press instead of scrolling
PointerSensor also receives touch-derived pointer events and its 5px
distance constraint fired before the TouchSensor's hold delay - the
browser claimed the gesture for scrolling (pointercancel) and the drag
died. Replaced with MouseSensor (desktop unchanged), TouchSensor now
activates on a 300ms hold (tolerance 8px), and all drag handles carry
touch-action: none so the browser never starts a scroll from them.
Applies to DocumentItemsEditor (offers/issued orders) and the invoice
items list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:30:01 +02:00
BOHA
40a859f5e1 feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer
Invoices now mirror the issued-orders document model:

- New invoice_sections table (CZ/EN rich-text "Obsah") edited via the
  shared SectionsEditor, printed inline right after the items on the
  PDF. Full-replace on update, same transaction as items.
- Printed notes dropped: the notes column is removed (migration merges
  existing content into internal_notes first); the form field is now
  "Interni poznamky", never printed. Legacy payloads sending notes are
  silently stripped.
- Form cleanup: Cislo faktury and Vystavil fields removed (number lives
  in the header, issued_by auto-fills); page header title restyled to
  the orders/offers pattern (number span + status chip).
- Unified per-page PDF header for the red-accent family: shared
  buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red
  rule) rendered by a Puppeteer headerTemplate on EVERY page of both
  invoices and issued orders (incl. the /file fallback render); body
  headers are print-hidden. htmlToPdf gained the headerTemplate option.
- Footer parity: invoices get the per-page "Vystavil + Strana X z Y"
  footer; the invoice bottom block (notice + QR/VAT recap + Prevzal)
  is break-inside: avoid so a page break can never split it.
- @page margins now match the template space (32mm top, 18mm bottom) -
  Chromium lays out by CSS @page margins, which also fixes issued
  orders' content running into the 18mm footer zone on full pages.

BREAKING CHANGE: invoices.notes column dropped (data merged into
internal_notes); deploy must run prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:02:37 +02:00
BOHA
ffae1a4e74 fix(offers): deleting an order returns the source offer to active
deleteOrder only nulled quotations.order_id and left status "ordered" -
a dead end (ordered only transitions to invalidated), so the offer could
never be ordered again and the "Vytvorit objednavku" button stayed
hidden. The delete transaction now reverts ordered -> active together
with the back-reference clear; regression test included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:02:19 +02:00
BOHA
5c9ebddf50 docs: consolidate CLAUDE.md (merge audit sections, dedupe, fix stale facts); runbooks to docs/release.md
Applied the two-review consolidation: merged the duplicate 2026-06-06/06-09 'Conventions (enforced)' sections into one canonical block (query-invalidation rule stated once instead of three times, Zod-helper 'tracked follow-up' vs 'MANDATORY' contradiction resolved, seed-is-dev-only stated once); removed stale-by-design facts (version pins, test counts, file counts, React 18 typo) and migration-era storytelling; fixed prisma migrate diff flags (--from-url removed in Prisma 7) and documented the non-interactive migration recipe; added the new rules earned this week (@db.Date UTC-truncation, document business rules incl. VAT-only-on-invoices and PO suppliers, document-platform conventions, shared-module reuse list, dashboard invalidation, app_test migrate step). Release/hotfix/drift/baseline runbooks moved to docs/release.md with a pointer (keeps prod details out of every-turn context). Also dropped the db:push npm script - it contradicted the golden rule. CLAUDE.md: 570 -> ~390 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:07:39 +02:00
BOHA
f268907848 chore(release): v2.4.4 - offers/issued-orders unification, PO sections+locking, per-page PDF footer, NAS dedupe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:57:05 +02:00
BOHA
dd8c699420 fix(nas): issued-order PDFs overwrite in place - no more _1 duplicate copies
saveIssuedPdf ran through uniquePath, so whenever two archive calls raced (the fire-and-forget ?save=1 after a save vs. the /file fallback, or two quick saves) the second write landed as <number>_1.pdf - and those copies were invisible to the reader and cleaner (exact-name match), accumulating forever. Now the issued archive writes to the deterministic Vydane/YYYY/MM/<number>.pdf and overwrites (same model as the offers manager), and cleanIssued also sweeps stale <number>_N.pdf duplicates so existing junk self-heals on the next save/delete. Document numbers are digit-only (never contain _), so the suffix match cannot hit a different document. uniquePath remains for received-invoice uploads where name collisions are legitimate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:55:27 +02:00
BOHA
268a947c85 feat(issued-orders-pdf): true per-page footer (Vystavil + Strana X z Y), sections flow inline
The PDF now carries a real repeating footer on EVERY page via Puppeteer's footerTemplate (Chromium has no CSS margin-box support, so this is the only true footer): 'Vystavil: <logged-in user>' on the left, 'Strana X z Y' / 'Page X of Y' centered, localized by the document language. htmlToPdf gained an optional footerTemplate option (empty header suppresses Chromium's default; bottom margin grows to 18mm). The old body footer pinned by flex min-height is gone. The 'Obsah' sections no longer start a new page with a repeated header - they flow inline right after the items/totals block and paginate naturally (break-inside: avoid per section). Applied at all three render sites (PDF route, ?save=1 archive, /file fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:44:11 +02:00
BOHA
75dc97516e feat(issued-orders)!: sections replace flat terms/notes fields; Obsah title
Per user decision the rich-text sections now carry all free-form PDF content on issued orders: dropped columns delivery_terms, payment_terms, issued_by, notes (migration applied to dev+test) and their PDF blocks + form fields. Kept: order_text (moved from the bottom card into the Zakladni udaje grid, replacing the Vystavil field) and internal_notes (now the only field in the bottom card, never printed). Sections card is titled 'Obsah' on issued orders; offers keep 'Rozsah projektu' and are untouched. Legacy clients sending the dropped keys are silently stripped (tested). PDF footer 'Vystavil:' stays - it prints the logged-in user, not the dropped column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:27:08 +02:00
BOHA
d1533ffc4d feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).

Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
  the PO template's red style (shared DocumentSectionSchema, one-transaction
  create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
  3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
  readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
  /file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
  number previews (also invoice previews), PUT returns assigned po_number

Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
  follows the table; order creation only from active offers) +
  valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
  issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
  linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
  DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
  dates, ints for positions); audit old/new values + koncept fallbacks;
  list id tiebreaks; stats include trimmed; NAS delete dedupe

Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
  document number (form field removed), one form layout/readonly convention,
  view-permission opens read-only everywhere (issued's editable-for-viewers
  hole closed), server-driven transition buttons, dirty guard + Enter submit
  on both, useApiMutation everywhere (new opt-in envelope mode), fixed
  infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
  numbers on offers, shared hardened per-row PDF flow (spinner, double-click
  guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
  states, query-lib cleanups (["offers","customers"] key, typed list rows,
  shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
  formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
  monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
  PDF language now comes from the document column

+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:39 +02:00
66 changed files with 7620 additions and 4284 deletions

755
CLAUDE.md
View File

@@ -1,25 +1,26 @@
# CLAUDE.md — boha-app-ts # CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js. 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 ## Tech Stack
| Layer | Technology | | Layer | Technology |
| -------------- | ---------------------------------------------------------------------------------------------------------------- | | -------------- | ----------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) | | Runtime | Node.js, TypeScript (strict, all tsconfigs) |
| HTTP Framework | Fastify 5.8.2 | | HTTP Framework | Fastify 5 |
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL | | 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 | | Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 | | Validation | Zod 4 |
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) | | Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
| Testing | Vitest 4.1.0 + Supertest | | Testing | Vitest + Supertest against a real `app_test` DB |
| PDF | Puppeteer 24.x | | PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
| Email | nodemailer 8.x | | Email / Cron | nodemailer / node-cron |
| Cron | node-cron 4.x | | AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
| AI | @anthropic-ai/sdk 0.102 — 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/ 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) ├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly) ├── services/ # Business logic (no classes, exported functions, own Prisma)
├── schemas/ # Zod validation schemas (one file per entity) ├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth) ├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
# security.ts (CSP, HSTS, security headers) ├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc. ├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
├── config/ # env.ts (config singleton, Date.toJSON override) ├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma) ├── admin/ # React 19 + MUI v7 frontend
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages │ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults │ ├── theme.ts / GlobalStyles.tsx
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities) │ ├── ui/ # MUI component kit — pages import from here
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here │ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/) │ ├── 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 │ ├── pages/ # One file per page/feature
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps │ ├── lib/queries/ # React Query options + useApiMutation
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, … │ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers │ └── utils/ # api.ts (apiFetch with token refresh), formatters
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …) └── __tests__/ # Vitest suites (server-side only; real app_test DB)
prisma/ prisma/ # schema.prisma (snake_case columns) + migrations/
├── schema.prisma # 52 models, MySQL, snake_case columns dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
``` ```
--- ---
@@ -62,508 +57,364 @@ dist-client/ # Built frontend (Vite, ES2020)
## Commands ## Commands
```bash ```bash
# Development npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
npm run dev # Starts server in watch mode (manage frontend separately) npm run build # server + client
npm run dev:server # tsx watch src/server.ts npm test # vitest run — against app_test via .env.test
npm run dev:client # Vite dev server 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
# 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 format # prettier --write . npm run format # prettier --write .
npx prisma generate # after every schema change (client is not committed)
# Database npx prisma studio # DB browser
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
``` ```
**Do not start the dev server.** The user manages it separately. **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 ## Environment Variables
Required: Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
``` Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
DATABASE_URL=mysql://user:pass@host:3306/dbname hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
JWT_SECRET=<64-char hex string> `ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
TOTP_ENCRYPTION_KEY=<64-char hex string> `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): `.env` for dev, `.env.test` for tests (both gitignored — **never edit env
files; the user manages them**).
```
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.
--- ---
## Architecture & Key Patterns ## Architecture & Key Patterns
### Request Flow ### Request flow
``` ```
Request → CORS → Cookie → Rate-limit → Security headers Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth() → requirePermission() / requireAnyPermission()
Zod schema validation (parseBody helper) parseBody(Schema) / parseId(param)
→ Route handler → Route handler → Service function → Prisma
Service function success(reply, data) | error(reply, czechMessage, status)
→ Prisma
→ success(reply, data) or error(reply, message, 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 ### Services
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error Plain exported async functions; services own Prisma, routes stay thin.
{ success: false, error: string } 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
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`. the reference). Legacy services return `{ error: czech, status }` — fine to
keep until touched; never the discriminated-union style. Respect soft-delete
### Service Pattern (`is_deleted: false`) in reads where the model has it.
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
### Permissions ### Permissions
```typescript `requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
// Route-level guard role bypasses checks (shortcut: `authData.roleName === "admin"`
fastify.addHook("preHandler", requirePermission("invoices.view")); ⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
// or multiple GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
fastify.addHook( (bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
"preHandler", permission opens pages read-only; an _edit_ permission unlocks fields and
requirePermission("invoices.view", "invoices.edit"), save buttons.
);
// Admin role bypasses all permission checks ### Audit
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit Logging `logAudit()` on every create/update/delete (and security-relevant actions)
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted. descriptions for unnumbered drafts. Audit failures are non-fatal.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
--- ---
## Date & Timezone Handling (Critical Gotcha) ## Dates & Timezones (Critical — three rules)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides 1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
`Date.prototype.toJSON()` to return local time (not UTC). This means: 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:0002: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. ## Document Modules (offers · orders · issued orders · invoices)
- **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. **Business rules (user/accountant decisions — do not revert):**
- The override exists for PHP migration compatibility and Czech date display.
- **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 ## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`. Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex). hex formats both supported); encrypted backup codes with their own
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`. `/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app. enrollment. Login flow: password → (if enrolled) single-use 5-min
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens. `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 ## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`). - 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
- **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`. unless `DATABASE_URL` names a `*test*` database.
- 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). - **After any schema change, apply migrations to the test DB too** or the
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling. suite fails: set `DATABASE_URL` to the app_test URL and run
- Use `buildApp()` helper to spin up the Fastify instance for tests. `npx prisma migrate deploy`.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks. - To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs. with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
`npx tsx prisma/seed.ts`.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.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 ## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`. - Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it. toasts via `useAlert()`.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them. - **React Query for all fetching** (query options live in
- 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). `src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`. effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
- 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. 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: - Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/`
- `["users"]` over `["users", "list"]` reach for raw `@mui/material` only for one-off layout/infra.
- `["trips"]` over `["trips", "vehicles"]` - Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without - Don't regress: status row tints are channel-alpha washes (never `.light`
updating every mutation handler. Inactive queries are only marked stale, not refetched, so fills — invisible text in dark mode); icon badges are solid `X.main` +
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem. white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain: `useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
fade; ConfirmDialogs stay open with `loading` during their request.
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data) - When refactoring pages, **preserve ALL data logic verbatim** (hooks,
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles) mutations, invalidate arrays, validation, permissions).
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
--- ---
## 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/`. Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
**Where styling lives** (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
- **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", { … })`. in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
- **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.) PDF → `extract-invoices` (structured output) → review cards → existing
- **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. `POST /received-invoices`. **`received_invoices.amount` is GROSS
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
**Building / editing pages** notes live in `docs/superpowers/specs/`.
- 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`).
--- ---
## 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`). **Golden rules:**
- **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/`.
--- - **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 **Making a schema change (this shell is non-interactive — `prisma migrate
dev` will refuse to run; use this recipe):**
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
- Lookup data, default settings, or any other row-level baseline that production needs
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
**What is NOT acceptable:**
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash ```bash
# Preview what SQL will run on production (no changes applied) # 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
npx prisma migrate diff \ # 2. Generate the migration SQL into a new folder:
--from-url "mysql://user:pass@prod:3306/app" \ mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
--to-schema-datamodel prisma/schema.prisma \ npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
--script > prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
# 3. Review the SQL, then apply + regenerate:
# Empty output = no diff. If it shows SQL, review it before deploying. 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 Deployment, drift checks, hotfix-migration shipping and baselining:
see **`docs/release.md`**.
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>
```
--- ---
## Conventions (enforcedverified by the 2026-06-06 audit) ## Enforced Conventions (single source — merged 2026-06 audits)
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`.
**Routes** **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.) - `success()`/`error()`/paginated helpers only; `parseId` for numeric params
- **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). (`if (id === null) return;`); `parseBody` for every body; permission guards
- **Bodies:** validate every body with `parseBody(Schema, request.body)``if ("error" in body) return error(reply, body.error, 400)`. on reads AND writes; `logAudit` with old/new values on every mutation.
- **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. - Role-management writes are admin-only and re-checked (no creating or
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`. rewriting the `admin` role).
**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.
**Schemas (Zod 4)** **Schemas (Zod 4)**
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`). - `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`. - **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
- User-facing messages in **Czech**; identifiers/keys in English. 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. - Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
- **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. columns make single-key sorts non-deterministic).
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects. - 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. - Never silently swallow — every catch logs (`console.*` in services; there
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`). is no request-scoped logger there). The only allowed silent catch is an
- **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. _expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
## 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 `0100`, month `112`) → `numberInRange`.
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
- An optional email a form may submit as `""`**`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
**Concurrency — locking discipline for stock / balance / sequence mutations**
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
**Permissions — guard reads, not just writes**
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error**`npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
**Safety**
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
--- ---
## Known Issues & Gotchas ## 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. 1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
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. 2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes. the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
pass unsanitized user data into Puppeteer templates; string-built HTML
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. (print views, PDF templates) must `escapeHtml` every interpolation.
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
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. with logged errors, and tests stub NAS reads.
4. **Prisma client**: regenerate after every schema change; it is not
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. 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.
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. 6. **Czech locale hardcoded** in messages/month names — intentional.
7. **Cross-login caches:** React Query keys are user-agnostic; the query
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git. cache is cleared on login/logout in AuthContext — keep it that way.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
11. **Seed file is dev-only**`prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
--- ---
## Release Process ## Release
1. Bump version in `package.json` Process, deployment commands, hotfix-migration shipping and verification
2. `npm run build` checklist: **`docs/release.md`**. Run the full test suite before tagging; the
3. Commit and tag (`git tag -a vX.Y.Z`) user picks version numbers. **Never push to production or restart services
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`) without explicit confirmation.**
5. Create tarball: `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)
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 prisma.config.ts 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`
- Regenerate the Prisma client: `npx prisma generate`**MANDATORY**.
`npm install` skips regeneration when dependencies didn't change, leaving a
stale client that still selects dropped/renamed columns → P2022 500s in
prod (bit the v2.4.0 supplier release).
- 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.

96
docs/release.md Normal file
View 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>
```

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.3", "version": "2.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.3", "version": "2.4.8",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.3", "version": "2.4.8",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -14,7 +14,6 @@
"preview": "vite preview", "preview": "vite preview",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:pull": "prisma db pull", "db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View File

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

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
DROP COLUMN `issued_by`,
DROP COLUMN `notes`,
DROP COLUMN `payment_terms`;

View File

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

View File

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

View File

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

View File

@@ -234,11 +234,11 @@ model invoices {
issued_by String? @db.VarChar(255) issued_by String? @db.VarChar(255)
billing_text String? @db.VarChar(500) billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
notes String? @db.Text
internal_notes String? @db.Text internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[] invoice_items invoice_items[]
invoice_sections invoice_sections[]
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1") 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") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
@@ -249,6 +249,19 @@ model invoices {
@@index([order_id], map: "order_id") @@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 { model item_templates {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? @db.VarChar(255) name String? @db.VarChar(255)
@@ -374,21 +387,36 @@ model issued_orders {
order_date DateTime? @db.Date order_date DateTime? @db.Date
delivery_date DateTime? @db.Date delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5) 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)
order_text String? @db.VarChar(500) order_text String? @db.VarChar(500)
notes String? @db.Text
internal_notes String? @db.Text internal_notes String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[] issued_order_items issued_order_items[]
issued_order_sections issued_order_sections[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk") suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([supplier_id], map: "issued_orders_supplier_id") @@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date") @@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 { model issued_order_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issued_order_id Int issued_order_id Int
@@ -780,11 +808,11 @@ model sklad_suppliers {
name String @db.VarChar(255) name String @db.VarChar(255)
ico String? @db.VarChar(20) ico String? @db.VarChar(20)
dic String? @db.VarChar(20) dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255) street String? @db.VarChar(255)
email String? @db.VarChar(255) city String? @db.VarChar(255)
phone String? @db.VarChar(50) postal_code String? @db.VarChar(20)
address String? @db.Text country String? @db.VarChar(100)
notes String? @db.Text custom_fields String? @db.LongText
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)

116
src/__tests__/ares.test.ts Normal file
View 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",
});
});
});

View File

@@ -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 prisma from "../config/database";
import { config } from "../config/env";
import { import {
previewIssuedOrderNumber, previewIssuedOrderNumber,
previewInvoiceNumber, previewInvoiceNumber,
@@ -19,23 +22,36 @@ import {
createOffer, createOffer,
updateOffer, updateOffer,
deleteOffer, deleteOffer,
invalidateOffer,
getOffer,
listOffers,
} from "../services/offers.service"; } 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. // Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = []; const issuedOrderIds: number[] = [];
const invoiceIds: number[] = []; const invoiceIds: number[] = [];
const offerIds: number[] = []; const offerIds: number[] = [];
const customerIds: number[] = [];
const linkedOrderIds: number[] = [];
afterEach(async () => { afterEach(async () => {
for (const id of issuedOrderIds) for (const id of issuedOrderIds)
await prisma.issued_orders.deleteMany({ where: { id } }); await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of invoiceIds) for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } }); 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) for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } }); await prisma.quotations.deleteMany({ where: { id } });
for (const id of customerIds)
await prisma.customers.deleteMany({ where: { id } });
issuedOrderIds.length = 0; issuedOrderIds.length = 0;
invoiceIds.length = 0; invoiceIds.length = 0;
linkedOrderIds.length = 0;
offerIds.length = 0; offerIds.length = 0;
customerIds.length = 0;
// Reset every sequence this suite may have touched so preview values are // 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. // stable across tests and we don't leak consumed numbers into other files.
await prisma.number_sequences.deleteMany({ 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 */ /* Issued orders */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -283,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
expect(after).toBe(before); 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");
});
});

View File

@@ -5,10 +5,14 @@ import {
invoiceTotalWithVat, invoiceTotalWithVat,
createInvoice, createInvoice,
updateInvoice, updateInvoice,
getInvoice,
listInvoices, listInvoices,
getInvoiceListTotals, getInvoiceListTotals,
} from "../services/invoices.service"; } from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema"; import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
@@ -168,7 +172,10 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
invoiceIds.push(draft.id); invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it. // Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" })); await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
);
let row = await prisma.invoices.findUnique({ where: { id: draft.id } }); let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF"); expect(row?.billing_text).toBe("Text na PDF");
@@ -182,6 +189,94 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
}); });
}); });
/* -------------------------------------------------------------------------- */
/* "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 */ /* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

View File

@@ -1,4 +1,12 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify"; import Fastify from "fastify";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import prisma from "../config/database"; import prisma from "../config/database";
@@ -19,7 +27,19 @@ import {
type IssuedOrderInput, type IssuedOrderInput,
} from "../services/issued-orders.service"; } from "../services/issued-orders.service";
import issuedOrdersRoutes from "../routes/admin/issued-orders"; import issuedOrdersRoutes from "../routes/admin/issued-orders";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf"; import issuedOrdersPdfRoutes, {
renderIssuedOrderHtml,
buildIssuedOrderPdfFooter,
} from "../routes/admin/issued-orders-pdf";
import { nasOrdersManager } from "../services/nas-financials-manager";
import { htmlToPdf } from "../utils/html-to-pdf";
// Keep Puppeteer OUT of the tests: the /:id/file live-render fallback calls
// htmlToPdf — stub the whole module so no browser ever launches.
vi.mock("../utils/html-to-pdf", () => ({
htmlToPdf: vi.fn(async () => Buffer.from("%PDF-FAKE issued-orders-test")),
closeBrowser: vi.fn(async () => {}),
}));
afterEach(async () => { afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
@@ -186,15 +206,18 @@ describe("updateIssuedOrder status transitions", () => {
expect("error" in bad && bad.error).toBe("invalid_transition"); expect("error" in bad && bad.error).toBe("invalid_transition");
}); });
it("locks items once confirmed", async () => { it("explicitly rejects a field edit once confirmed (not_editable), items unchanged", async () => {
const order = await mkIssued({ const order = await mkIssued({
items: [{ description: "A", quantity: 1, unit_price: 10 }], items: [{ description: "A", quantity: 1, unit_price: 10 }],
}); });
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, { // The old behavior silently DROPPED the field edit; now it is an explicit
// not_editable rejection (offers parity) — and the items stay unchanged.
const res = await updateIssuedOrder(order.id, {
items: [{ description: "B", quantity: 9, unit_price: 99 }], items: [{ description: "B", quantity: 9, unit_price: 99 }],
}); });
expect("error" in res && res.error).toBe("not_editable");
const items = await prisma.issued_order_items.findMany({ const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id }, where: { issued_order_id: order.id },
}); });
@@ -202,6 +225,18 @@ describe("updateIssuedOrder status transitions", () => {
expect(items[0].description).toBe("A"); expect(items[0].description).toBe("A");
}); });
it("status-only transition payloads still work when not editable", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await updateIssuedOrder(order.id, { status: "completed" });
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("completed");
});
it("rejects a nonexistent supplier_id on update", async () => { it("rejects a nonexistent supplier_id on update", async () => {
const order = await mkIssued({}); const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
@@ -289,6 +324,10 @@ describe("listIssuedOrders month filter", () => {
let app: ReturnType<typeof Fastify> | null = null; let app: ReturnType<typeof Fastify> | null = null;
let adminToken = ""; let adminToken = "";
let adminUserId = 0;
let adminFullName = "";
let lockerToken = "";
let lockerUserId = 0;
let noPermToken = ""; let noPermToken = "";
let noPermUserId = 0; let noPermUserId = 0;
let noPermRoleId = 0; let noPermRoleId = 0;
@@ -310,6 +349,9 @@ beforeAll(async () => {
await app.register(issuedOrdersRoutes, { await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders", prefix: "/api/admin/issued-orders",
}); });
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
const admin = await prisma.users.findFirst({ const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } }, where: { roles: { name: "admin" } },
@@ -320,6 +362,29 @@ beforeAll(async () => {
username: admin.username, username: admin.username,
roleName: "admin", roleName: "admin",
}); });
adminUserId = admin.id;
adminFullName = `${admin.first_name} ${admin.last_name}`.trim();
// A SECOND user with edit rights (admin role → bypasses permission checks),
// to exercise the 423 lock conflict + the locked_by enrichment on GET /:id.
const locker = await prisma.users.create({
data: {
username: "io_test_locker",
email: "io_test_locker@test.local",
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Druhý",
last_name: "Editor",
is_active: true,
role_id: admin.role_id,
},
});
lockerUserId = locker.id;
lockerToken = generateToken({
id: locker.id,
username: locker.username,
roleName: "admin",
});
// A role with NO orders permissions, to prove the lookup endpoint is gated. // A role with NO orders permissions, to prove the lookup endpoint is gated.
const noPermRole = await prisma.roles.create({ const noPermRole = await prisma.roles.create({
@@ -351,6 +416,8 @@ beforeAll(async () => {
}); });
afterAll(async () => { afterAll(async () => {
if (lockerUserId)
await prisma.users.delete({ where: { id: lockerUserId } }).catch(() => {});
if (noPermUserId) if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {}); await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId) if (noPermRoleId)
@@ -383,9 +450,20 @@ describe("GET /api/admin/issued-orders/suppliers", () => {
const row = body.data.find((s: { id: number }) => s.id === active.id); const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel"); expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678"); expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present. // Lightweight lookup shape — all picker fields present (structured
// address since the 2026-06 supplier customers-model split; the
// email/phone columns were dropped in favour of custom_fields).
expect(Object.keys(row).sort()).toEqual( expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(), [
"city",
"country",
"dic",
"ico",
"id",
"name",
"postal_code",
"street",
].sort(),
); );
}); });
@@ -415,6 +493,157 @@ describe("POST /api/admin/issued-orders supplier validation", () => {
}); });
}); });
describe("POST /api/admin/issued-orders legacy dropped fields", () => {
it("silently ignores the removed flat-text keys (old clients keep working)", async () => {
// delivery_terms/payment_terms/issued_by/notes were dropped from the
// schema AND the table — a stale client still sending them must save
// cleanly (non-strict Zod strips unknown keys) with the kept fields intact.
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: {
notes: "<p>stará poznámka</p>",
delivery_terms: "DAP, sklad odběratele",
payment_terms: "splatnost 14 dní",
issued_by: "Starý klient",
order_text: "Objednáváme dle smlouvy:",
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
createdIds.push(body.data.id);
const row = await prisma.issued_orders.findUnique({
where: { id: body.data.id },
});
expect(row).not.toBeNull();
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
});
});
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { order_text: "pokus o úpravu" },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
});
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { status: "completed" },
});
expect(res.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("completed");
});
});
describe("issued-order audit old/new values", () => {
it("PUT writes an audit row carrying oldValues and newValues", async () => {
const order = await mkIssued({ internal_notes: "před úpravou" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: { internal_notes: "po úpravě" },
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: {
entity_type: "issued_order",
entity_id: order.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!).internal_notes).toBe("před úpravou");
expect(JSON.parse(audit!.new_values!).internal_notes).toBe("po úpravě");
// Unnumbered drafts read "koncept", never "null".
expect(audit!.description).toContain("koncept");
expect(audit!.description).not.toContain("null");
});
it("DELETE writes an audit row carrying oldValues", async () => {
const order = await mkIssued({ internal_notes: "ke smazání" });
const res = await app!.inject({
method: "DELETE",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: {
entity_type: "issued_order",
entity_id: order.id,
action: "delete",
},
orderBy: { id: "desc" },
});
expect(audit).not.toBeNull();
expect(JSON.parse(audit!.old_values!).internal_notes).toBe("ke smazání");
expect(audit!.description).toContain("koncept");
});
});
describe("GET /api/admin/issued-orders-pdf/:id language default", () => {
it("defaults to the order's language column; ?lang= is an explicit override", async () => {
const order = await mkIssued({
language: "en",
items: [{ description: "Lang test", quantity: 1, unit_price: 10 }],
});
// Echo the rendered HTML through the htmlToPdf stub so the response body
// is assertable (still no Puppeteer). Once-scoped: the module-level fake
// ("%PDF-FAKE …") stays in place for the other tests.
vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) =>
Buffer.from(html),
);
const en = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders-pdf/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(en.statusCode).toBe(200);
expect(en.rawPayload.toString()).toContain("PURCHASE ORDER No.");
vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) =>
Buffer.from(html),
);
const cs = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders-pdf/${order.id}?lang=cs`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(cs.statusCode).toBe(200);
expect(cs.rawPayload.toString()).toContain("OBJEDNÁVKA č.");
});
});
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* PDF render */ /* PDF render */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -425,10 +654,6 @@ describe("renderIssuedOrderHtml", () => {
order_date: new Date("2026-06-09T12:00:00"), order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null, delivery_date: null,
currency: "CZK", currency: "CZK",
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
issued_by: null,
}; };
const items = [ const items = [
{ {
@@ -442,12 +667,15 @@ describe("renderIssuedOrderHtml", () => {
const issuer = { name: "Jan Novák" }; const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns. // sklad_suppliers shape: structured address + ico/dic columns.
const supplier = { const supplier = {
name: "Dodavatel s.r.o.", name: "Dodavatel s.r.o.",
ico: "12345678", ico: "12345678",
dic: "CZ12345678", dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha", street: "Průmyslová 5",
city: "Praha",
postal_code: "190 00",
country: "Česká republika",
}; };
it("renders the PO number, items, and both party names (PO direction)", () => { it("renders the PO number, items, and both party names (PO direction)", () => {
@@ -471,7 +699,7 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Odběratel"); expect(html).toContain("Odběratel");
}); });
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => { it("renders the structured supplier address plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,
items, items,
@@ -480,33 +708,41 @@ describe("renderIssuedOrderHtml", () => {
"cs", "cs",
issuer, issuer,
); );
// The single Text address blob becomes one address line per newline. // street / "PSČ Město" / country — one address line each.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>'); expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>'); expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain('<div class="address-line">Česká republika</div>');
expect(html).toContain("IČ: 12345678"); expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678"); expect(html).toContain("DIČ: CZ12345678");
}); });
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => { it("skips empty address parts and missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,
items, items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" }, { name: "Jednořádkový", street: "Ulice 1", city: "Praha" },
null, null,
"cs", "cs",
issuer, issuer,
); );
expect(html).toContain( expect(html).toContain('<div class="address-line">Ulice 1</div>');
'<div class="address-line">Ulice 1, 100 00 Praha</div>', // City without PSČ renders alone, no stray separator.
); expect(html).toContain('<div class="address-line">Praha</div>');
expect(html).not.toContain("IČ: "); expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: "); expect(html).not.toContain("DIČ: ");
}); });
it("strips script tags from notes", () => { it("escapes HTML in the order_text heading (no raw tags reach the PDF)", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); const html = renderIssuedOrderHtml(
{ ...order, order_text: '<script>alert(1)</script>Text "X"' },
items,
null,
null,
"cs",
issuer,
);
expect(html).not.toContain("<script>"); expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)"); expect(html).toContain("&lt;script&gt;");
}); });
it("renders the custom order_text heading when set, default when not", () => { it("renders the custom order_text heading when set, default when not", () => {
@@ -537,8 +773,8 @@ describe("renderIssuedOrderHtml", () => {
// No VAT anywhere — the PO is not a tax document. // No VAT anywhere — the PO is not a tax document.
expect(html).not.toContain("%DPH"); expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<"); expect(html).not.toContain(">DPH<");
// Line total = netto (2 × 100). // Line total = netto (2 × 100), formatted with the per-currency symbol.
expect(html).toContain('<td class="right total-cell">200,00</td>'); expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH"); expect(html).toContain("Celkem bez DPH");
// No Mezisoučet row (it would duplicate the grand total) and no // No Mezisoučet row (it would duplicate the grand total) and no
// prices-excl.-VAT notice (user removed it — the total label suffices). // prices-excl.-VAT notice (user removed it — the total label suffices).
@@ -553,13 +789,500 @@ describe("renderIssuedOrderHtml", () => {
expect(html).not.toContain("Prices are exclusive of VAT"); expect(html).not.toContain("Prices are exclusive of VAT");
}); });
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => { it("amounts carry the currency symbol; the 'Částky jsou uvedeny v' note is gone", () => {
const cz = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Unit price, line total and grand total all go through formatCurrency.
expect(cz).toContain("100,00 Kč");
expect(cz).toContain("200,00 Kč");
expect(cz).not.toContain("Částky jsou uvedeny v");
expect(cz).not.toContain("currency-note");
const en = renderIssuedOrderHtml(
{ ...order, currency: "EUR" },
items,
null,
null,
"en",
issuer,
);
expect(en).toContain("200,00 €");
expect(en).not.toContain("Amounts are in");
});
it("body carries NO footer — Vystavil moved to the Puppeteer footer template", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line. // The issuer prints in the per-page footerTemplate, never in the body.
expect(html).toContain("Jan Novák"); expect(html).not.toContain("Jan Novák");
expect(html).not.toContain("Vystavil:");
expect(html).not.toContain("invoice-footer");
expect(html).not.toContain("E-mail:"); expect(html).not.toContain("E-mail:");
// The old two-column signature footer is gone.
expect(html).not.toContain("footer-row");
expect(html).not.toContain("Schválil:"); expect(html).not.toContain("Schválil:");
}); });
it("buildIssuedOrderPdfFooter: Vystavil left, page counter centered, per language", () => {
const cs = buildIssuedOrderPdfFooter("cs", "Jan Novák");
expect(cs).toContain("Vystavil:");
expect(cs).toContain("Jan Novák");
expect(cs).toContain('Strana <span class="pageNumber"></span>');
expect(cs).toContain('z <span class="totalPages"></span>');
const en = buildIssuedOrderPdfFooter("en", "Jan Novák");
expect(en).toContain("Issued by:");
expect(en).toContain('Page <span class="pageNumber"></span>');
expect(en).toContain('of <span class="totalPages"></span>');
// Escaping: a malicious issuer name must not break out of the template.
const evil = buildIssuedOrderPdfFooter("cs", '<img src=x onerror="1">');
expect(evil).not.toContain("<img");
});
});
/* -------------------------------------------------------------------------- */
/* Sections (rich-text CZ/EN, mirrors offers' scope_sections) */
/* -------------------------------------------------------------------------- */
describe("CreateIssuedOrderSchema sections", () => {
it("accepts sections with DB-aligned 500-char titles and coerces position", () => {
const longTitle = "T".repeat(400); // >255 — the old offers cap under-shot
const ok = CreateIssuedOrderSchema.safeParse({
sections: [
{ title: longTitle, title_cz: "Rozsah", content: "<p>x</p>" },
{ title: null, position: "3" },
],
});
expect(ok.success).toBe(true);
if (ok.success) {
expect(ok.data.sections![0].title).toBe(longTitle);
expect(ok.data.sections![1].position).toBe(3);
}
const bad = CreateIssuedOrderSchema.safeParse({
sections: [{ title: "T".repeat(501) }],
});
expect(bad.success).toBe(false);
});
});
describe("issued-order sections (service)", () => {
it("creates sections ordered by position and maps them on getIssuedOrder", async () => {
const order = await mkIssued({
sections: [
{ title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>Obsah A</p>" },
{ title: "Second", content: "<p>Obsah B</p>" },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(2);
expect(detail!.sections[0].title).toBe("Scope EN");
expect(detail!.sections[0].title_cz).toBe("Rozsah CZ");
expect(detail!.sections[0].position).toBe(0);
expect(detail!.sections[1].title).toBe("Second");
expect(detail!.sections[1].position).toBe(1);
});
it("full-replaces sections on update and cascades them on delete", async () => {
const order = await mkIssued({
sections: [
{ title: "A", content: "<p>a</p>" },
{ title: "B", content: "<p>b</p>" },
],
});
await updateIssuedOrder(order.id, {
sections: [
{ title_cz: "Jediná sekce", content: "<p>Nové</p>", position: 5 },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(1);
expect(detail!.sections[0].title_cz).toBe("Jediná sekce");
expect(detail!.sections[0].position).toBe(5);
await deleteIssuedOrder(order.id);
expect(
(
await prisma.issued_order_sections.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
});
});
describe("updateIssuedOrder atomicity (single transaction)", () => {
it("rolls back the header write when the items replace fails", async () => {
const order = await mkIssued({
internal_notes: "původní",
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
// issued_order_items.description is VarChar(500) — 600 chars makes the
// in-tx createMany fail at the DB, which must roll back the header update
// (the old two-transaction shape left the header committed: torn write).
const tooLong = "x".repeat(600);
await expect(
updateIssuedOrder(order.id, {
internal_notes: "změněno",
items: [{ description: tooLong, quantity: 1, unit_price: 1 }],
}),
).rejects.toThrow();
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.internal_notes).toBe("původní");
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
});
/* -------------------------------------------------------------------------- */
/* PO number uniqueness + preview collision-advance */
/* -------------------------------------------------------------------------- */
describe("po_number uniqueness", () => {
it("returns a clean 409 for an explicit duplicate po_number, not a 500", async () => {
await mkIssued({ po_number: "IODUP1" });
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { po_number: "IODUP1" },
});
expect(res.statusCode).toBe(409);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Číslo objednávky je již použito");
});
it("preview advances past a number already used by an existing order", async () => {
const p = await previewIssuedOrderNumber();
const order = await mkIssued({ po_number: p.number });
expect(order.po_number).toBe(p.number);
const p2 = await previewIssuedOrderNumber();
expect(p2.number).not.toBe(p.number);
});
});
/* -------------------------------------------------------------------------- */
/* Edit locking (mirrors offers/quotations.ts — 10s lock + heartbeat) */
/* -------------------------------------------------------------------------- */
describe("issued-order edit locking", () => {
const lockUrl = (id: number, action: string) =>
`/api/admin/issued-orders/${id}/${action}`;
it("locks for the first editor; a second user gets 423 with the holder's name", async () => {
const order = await mkIssued({});
const r1 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(r1.statusCode).toBe(200);
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(423);
expect(r2.json().error).toContain("Objednávku právě upravuje");
expect(r2.json().error).toContain(adminFullName);
});
it("re-locking by the SAME holder succeeds (refresh, not conflict)", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const again = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(again.statusCode).toBe(200);
});
it("heartbeat extends the lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// Age the lock close to expiry, then heartbeat — locked_at must be fresh.
const aged = new Date(Date.now() - 9000);
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: aged },
});
const hb = await app!.inject({
method: "POST",
url: lockUrl(order.id, "heartbeat"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(hb.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(Date.now() - new Date(row!.locked_at!).getTime()).toBeLessThan(5000);
});
it("unlock releases the lock so another user can take it", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const ul = await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(ul.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBeNull();
expect(row!.locked_at).toBeNull();
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
});
it("unlock by a non-holder does NOT release someone else's lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(adminUserId);
});
it("a stale lock (>30s without heartbeat) is takeable by another user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: new Date(Date.now() - 31000) },
});
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(lockerUserId);
});
it("GET /:id enriches locked_by ONLY for the other user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// The holder sees no lock banner (locked_by null).
const asHolder = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(asHolder.statusCode).toBe(200);
expect(asHolder.json().data.locked_by).toBeNull();
// The other user sees who holds the lock (offers' enrichment shape).
const asOther = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(asOther.statusCode).toBe(200);
const lockedBy = asOther.json().data.locked_by;
expect(lockedBy).not.toBeNull();
expect(lockedBy.user_id).toBe(adminUserId);
expect(lockedBy.full_name).toBe(adminFullName);
expect(typeof lockedBy.username).toBe("string");
});
});
/* -------------------------------------------------------------------------- */
/* GET /:id/file — archived PDF with live-render fallback */
/* -------------------------------------------------------------------------- */
describe("GET /api/admin/issued-orders/:id/file", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("404s for a draft without a po_number", async () => {
const order = await mkIssued({});
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("serves the archived PDF bytes inline on an archive hit", async () => {
const order = await mkIssued({ po_number: "IOTESTPOHIT" });
const bytes = Buffer.from("%PDF-ARCHIVED issued");
const readSpy = vi
.spyOn(nasOrdersManager, "readIssuedByNumber")
.mockReturnValue({ data: bytes, fileName: "IOTESTPOHIT.pdf" });
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
expect(res.headers["content-disposition"]).toContain("inline");
expect(res.headers["content-disposition"]).toContain(
"Objednavka-IOTESTPOHIT.pdf",
);
expect(res.rawPayload.equals(bytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith("IOTESTPOHIT");
});
it("falls back to a live render and re-archives on an archive miss", async () => {
const order = await mkIssued({
po_number: "IOTESTPOMISS",
order_date: "2026-03-15",
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
sections: [{ title_cz: "Rozsah", content: "<p>Obsah</p>" }],
});
vi.spyOn(nasOrdersManager, "readIssuedByNumber").mockReturnValue(null);
vi.spyOn(nasOrdersManager, "isConfigured").mockReturnValue(true);
const cleanSpy = vi
.spyOn(nasOrdersManager, "cleanIssued")
.mockImplementation(() => {});
const saveSpy = vi
.spyOn(nasOrdersManager, "saveIssuedPdf")
.mockReturnValue("Vydané/2026/03/IOTESTPOMISS.pdf");
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
// The stubbed htmlToPdf output is served (live render, no Puppeteer).
expect(res.rawPayload.toString()).toContain("%PDF-FAKE");
// The fresh PDF was archived to the NAS under the order_date year/month.
expect(cleanSpy).toHaveBeenCalledWith("IOTESTPOMISS");
expect(saveSpy).toHaveBeenCalledWith(
"IOTESTPOMISS",
2026,
3,
expect.any(Buffer),
);
});
});
/* -------------------------------------------------------------------------- */
/* PDF — sections scope page */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml sections (scope page)", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
};
const items = [
{
description: "Materiál",
item_description: null,
quantity: 1,
unit: null,
unit_price: 100,
},
];
const issuer = { name: "Jan Novák" };
it("renders sections INLINE after the items (no new page, no repeated header); cs prefers title_cz, sanitizes content", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
{
title: "Scope EN",
title_cz: "Rozsah CZ",
content: "<p>Obsah</p><script>alert(1)</script>",
},
{ title: "Only EN", title_cz: null, content: null },
]);
expect(html).toContain('class="scope-sections"');
// No separate page: no page-break wrapper, header NOT repeated
// (<title> + the one page header = exactly 2 occurrences).
expect(html).not.toContain("scope-page");
expect(html).not.toContain("page-break-before");
expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(2);
// Sections flow INSIDE the content block, after the items/totals.
expect(html.indexOf('class="scope-sections"')).toBeGreaterThan(
html.indexOf("total-cell"),
);
expect(html.indexOf('class="scope-sections"')).toBeLessThan(
html.indexOf("/.invoice-content"),
);
// cs → title_cz preferred when non-empty…
expect(html).toContain("Rozsah CZ");
expect(html).not.toContain("Scope EN");
// …and falls back to the EN title when title_cz is empty.
expect(html).toContain("Only EN");
expect(html).toContain("Obsah");
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
});
it("uses the EN title for lang=en", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer, [
{ title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>x</p>" },
]);
expect(html).toContain("Scope EN");
expect(html).not.toContain("Rozsah CZ");
});
it("suppresses the sections block when every section is empty (tag-stripped)", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
{ title: "", title_cz: " ", content: "<p><br></p>" },
]);
expect(html).not.toContain('class="scope-sections"');
});
it("omits the sections block when there are no sections at all", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).not.toContain('class="scope-sections"');
});
}); });

View File

@@ -142,6 +142,84 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
expect(project?.project_number).not.toBe(order?.order_number); expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId); 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)", () => { describe("deleteOrder releases the project number (release regression)", () => {

View File

@@ -53,4 +53,29 @@ describe("NasDocumentManager issued-PDF round-trip", () => {
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")), blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
).toBeNull(); ).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"]);
});
}); });

View File

@@ -5,6 +5,8 @@ import prisma from "../config/database";
import { config } from "../config/env"; import { config } from "../config/env";
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf"; import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
import offersPdfRoutes from "../routes/admin/offers-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 // Offers, received orders (their confirmation PDF) and issued orders are NOT
// tax documents — VAT must never appear on them. These tests pin that contract // tax documents — VAT must never appear on them. These tests pin that contract
@@ -73,6 +75,26 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
); );
// Grand total stays 200,00 — the excluded line doesn't count. // Grand total stays 200,00 — the excluded line doesn't count.
expect(html).toContain("200,00 CZK"); 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"); expect(html).not.toContain("1 199,00 CZK");
}); });
}); });
@@ -84,10 +106,12 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
let app: ReturnType<typeof Fastify> | null = null; let app: ReturnType<typeof Fastify> | null = null;
let adminToken = ""; let adminToken = "";
const createdQuotationIds: number[] = []; const createdQuotationIds: number[] = [];
const createdInvoiceIds: number[] = [];
beforeAll(async () => { beforeAll(async () => {
app = Fastify({ logger: false }); app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" }); await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
const admin = await prisma.users.findFirst({ const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } }, where: { roles: { name: "admin" } },
@@ -103,6 +127,8 @@ beforeAll(async () => {
afterAll(async () => { afterAll(async () => {
for (const id of createdQuotationIds) for (const id of createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } }); await prisma.quotations.deleteMany({ where: { id } });
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close(); if (app) await app.close();
}); });
@@ -144,4 +170,78 @@ describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
expect(html).not.toContain("Mezisoučet"); expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Celkem k úhradě"); 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ě");
});
}); });

View File

@@ -240,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
).toBe(false); ).toBe(false);
}); });
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => { it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
expect( // email/phone/contact_person/notes/address columns were replaced by the
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success, // customers-model custom_fields blob (2026-06); legacy clients still
).toBe(true); // sending them must not 400 — z.object strips unknown keys.
expect( const parsed = CreateSupplierSchema.safeParse({
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" }) name: "Dodavatel",
.success, email: "x@y.cz",
).toBe(true); phone: "123",
expect( contact_person: "Jan",
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" }) notes: "n",
.success, address: "Ulice 1",
).toBe(false); 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", () => { it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {

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

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

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

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

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

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

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

View File

@@ -43,6 +43,14 @@ export interface InvoiceItem {
position?: number; position?: number;
} }
export interface InvoiceSection {
id?: number;
title: string | null;
title_cz: string | null;
content: string | null;
position?: number | null;
}
export interface InvoiceDetail { export interface InvoiceDetail {
id: number; id: number;
invoice_number: string; invoice_number: string;
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
vat_rate: number; vat_rate: number;
apply_vat: boolean; apply_vat: boolean;
exchange_rate: string; exchange_rate: string;
notes?: string; internal_notes?: string | null;
payment_method?: string; payment_method?: string;
variable_symbol?: string; variable_symbol?: string;
constant_symbol?: string; constant_symbol?: string;
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
bank_account?: string; bank_account?: string;
bank_account_id?: number | null; bank_account_id?: number | null;
items?: InvoiceItem[]; items?: InvoiceItem[];
sections?: InvoiceSection[];
subtotal: number; subtotal: number;
vat_amount: number; vat_amount: number;
total: number; total: number;

View File

@@ -1,5 +1,11 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter"; 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 { export interface IssuedOrder {
id: number; id: number;
@@ -19,9 +25,10 @@ export interface Supplier {
name: string; name: string;
ico: string | null; ico: string | null;
dic: string | null; dic: string | null;
address: string | null; street: string | null;
email: string | null; city: string | null;
phone: string | null; postal_code: string | null;
country: string | null;
} }
export interface IssuedOrderItem { export interface IssuedOrderItem {
@@ -34,19 +41,31 @@ export interface IssuedOrderItem {
position?: number; 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 { export interface IssuedOrderDetail extends IssuedOrder {
exchange_rate: number | string | null; exchange_rate: number | string | null;
delivery_date: string | null; delivery_date: string | null;
language: string | null; language: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
order_text: string | null; order_text: string | null;
notes: string | null;
internal_notes: string | null; internal_notes: string | null;
items: IssuedOrderItem[]; items: IssuedOrderItem[];
sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null; supplier: Record<string, unknown> | null;
valid_transitions: string[]; 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 // Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
@@ -65,6 +84,7 @@ export const issuedOrderListOptions = (filters: {
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string; status?: string;
supplier_id?: number;
month?: number; month?: number;
year?: number; year?: number;
}) => }) =>
@@ -78,6 +98,8 @@ export const issuedOrderListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status); 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.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year)); if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();
@@ -87,14 +109,10 @@ export const issuedOrderListOptions = (filters: {
}, },
}); });
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const issuedOrderStatsOptions = (filters: { export const issuedOrderStatsOptions = (filters: {
search?: string; search?: string;
status?: string; status?: string;
supplier_id?: number;
month?: number; month?: number;
year?: number; year?: number;
}) => }) =>
@@ -104,6 +122,8 @@ export const issuedOrderStatsOptions = (filters: {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search); if (filters.search) params.set("search", filters.search);
if (filters.status) params.set("status", filters.status); 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.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year)); if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();
@@ -120,4 +140,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
queryFn: () => queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`), jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!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 || ""),
}); });

View File

@@ -9,6 +9,32 @@ export interface ApiError extends Error {
status?: number; 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"; export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> { interface ApiResponseBody<T> {
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string); url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod); method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn; body?: TIn;
envelope?: boolean;
}): Promise<TOut> { }): Promise<TOut> {
const url = const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.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; throw err;
} }
if (opts.envelope) {
return { data: result.data, message: result.message } as TOut;
}
return result.data as TOut; return result.data as TOut;
} }
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
method: HttpMethod | ((input: TIn) => HttpMethod); method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */ /** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[]; 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> & opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">, Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) { ) {
const { url, method, invalidate, onSuccess, ...rest } = opts; const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({ return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) => mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }), performMutation<TIn, TOut>({ url, method, body: input, envelope }),
...rest, ...rest,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) { if (invalidate) {

View File

@@ -52,7 +52,9 @@ export interface Customer {
export const offerCustomersOptions = () => export const offerCustomersOptions = () =>
queryOptions({ 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"), queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
staleTime: 2 * 60_000, staleTime: 2 * 60_000,
}); });
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"), 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: { export const offerListOptions = (filters: {
search?: string; search?: string;
sort?: string; sort?: string;
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
if (filters.customer_id) if (filters.customer_id)
params.set("customer_id", String(filters.customer_id)); params.set("customer_id", String(filters.customer_id));
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`); return paginatedJsonQuery<Quotation>(
`/api/admin/offers${qs ? `?${qs}` : ""}`,
);
}, },
}); });
@@ -162,6 +181,8 @@ export interface OfferDetailData {
status: string; status: string;
order: OfferOrderInfo | null; order: OfferOrderInfo | null;
locked_by: OfferLockInfo | 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) => export const offerDetailOptions = (id: string | undefined) =>

View File

@@ -40,6 +40,7 @@ export const projectListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["projects", "list", filters], queryKey: ["projects", "list", filters],
@@ -50,6 +51,7 @@ export const projectListOptions = (filters: {
if (filters.order) params.set("order", filters.order); if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery<Project>( return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`, `/api/admin/projects${qs ? `?${qs}` : ""}`,

View File

@@ -157,16 +157,23 @@ export interface WarehouseLocation {
is_active: boolean; is_active: boolean;
} }
export interface WarehouseSupplierCustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
export interface WarehouseSupplier { export interface WarehouseSupplier {
id: number; id: number;
name: string; name: string;
ico: string | null; ico: string | null;
dic: string | null; dic: string | null;
contact_person: string | null; street: string | null;
email: string | null; city: string | null;
phone: string | null; postal_code: string | null;
address: string | null; country: string | null;
notes: string | null; custom_fields?: WarehouseSupplierCustomField[];
is_active: boolean; is_active: boolean;
} }

View File

@@ -24,11 +24,14 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor"; import RichEditor from "../components/RichEditor";
import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
@@ -198,7 +201,7 @@ interface InvoiceForm {
constant_symbol: string; constant_symbol: string;
issued_by: string; issued_by: string;
billing_text: string; billing_text: string;
notes: string; internal_notes: string;
language: string; language: string;
bank_account_id: number | string; bank_account_id: number | string;
bank_name: string; bank_name: string;
@@ -275,7 +278,11 @@ function SortableInvoiceRow({
{...listeners} {...listeners}
title="Přetáhnout" title="Přetáhnout"
aria-label="Přetáhnout" aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }} sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
> >
{DragIcon} {DragIcon}
</IconButton> </IconButton>
@@ -385,7 +392,7 @@ function SortableInvoiceRow({
{...listeners} {...listeners}
title="Přetáhnout" title="Přetáhnout"
aria-label="Přetáhnout" aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }} sx={{ cursor: "grab", color: "text.secondary", touchAction: "none" }}
> >
{DragIcon} {DragIcon}
</IconButton> </IconButton>
@@ -508,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( const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 }, activationConstraint: { delay: 300, tolerance: 8 },
}), }),
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
@@ -540,7 +553,7 @@ export default function InvoiceDetail() {
constant_symbol: "0308", constant_symbol: "0308",
issued_by: user?.fullName || "", issued_by: user?.fullName || "",
billing_text: "", billing_text: "",
notes: "", internal_notes: "",
language: "cs", language: "cs",
bank_account_id: "", bank_account_id: "",
bank_name: "", bank_name: "",
@@ -550,6 +563,9 @@ export default function InvoiceDetail() {
})); }));
const [dueDays, setDueDays] = useState(14); 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[]>(() => [ const [items, setItems] = useState<InvoiceItem[]>(() => [
{ {
_key: "inv-1", _key: "inv-1",
@@ -626,7 +642,6 @@ export default function InvoiceDetail() {
}); });
// ─── Edit mode state ─── // ─── Edit mode state ───
const [notes, setNotes] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null); const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{ const [statusConfirm, setStatusConfirm] = useState<{
show: boolean; show: boolean;
@@ -696,7 +711,7 @@ export default function InvoiceDetail() {
constant_symbol: inv.constant_symbol || "0308", constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "", issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "", billing_text: inv.billing_text || "",
notes: inv.notes || "", internal_notes: inv.internal_notes || "",
language: inv.language || "cs", language: inv.language || "cs",
bank_account_id: matchedBankId, bank_account_id: matchedBankId,
bank_name: inv.bank_name || "", bank_name: inv.bank_name || "",
@@ -705,7 +720,6 @@ export default function InvoiceDetail() {
bank_account: inv.bank_account || "", bank_account: inv.bank_account || "",
}; };
setForm(formData); setForm(formData);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || ""); setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates // Calculate dueDays from existing dates
@@ -741,10 +755,22 @@ export default function InvoiceDetail() {
setItems(mappedItems); 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 // Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({ initialSnapshotRef.current = JSON.stringify({
form: formData, form: formData,
items: mappedItems, items: mappedItems,
sections: mappedSections,
}); });
setDataReady(true); setDataReady(true);
@@ -841,13 +867,15 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data. // Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form. // Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) { if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items }); initialSnapshotRef.current = JSON.stringify({ form, items, sections });
} }
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false; if (!initialSnapshotRef.current) return false;
return JSON.stringify({ form, items }) !== initialSnapshotRef.current; return (
}, [form, items]); JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
@@ -1005,6 +1033,12 @@ export default function InvoiceDetail() {
unit_price: Number(item.unit_price) || 0, unit_price: Number(item.unit_price) || 0,
position: i, 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 // 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 // / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1017,7 +1051,7 @@ export default function InvoiceDetail() {
const data = await saveMutation.mutateAsync(payload); const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); 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) { if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`); navigate(`/invoices/${data.invoice_id}`);
} }
@@ -1129,7 +1163,16 @@ export default function InvoiceDetail() {
mb: 3, 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 <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"
@@ -1148,7 +1191,13 @@ export default function InvoiceDetail() {
}} }}
> >
<Typography variant="h4"> <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> </Typography>
<StatusChip <StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)} label={statusLabel(INVOICE_STATUS, invoice.status)}
@@ -1281,11 +1330,6 @@ export default function InvoiceDetail() {
<Field label="Variabilní symbol"> <Field label="Variabilní symbol">
<Typography variant="body2">{invoice.invoice_number}</Typography> <Typography variant="body2">{invoice.invoice_number}</Typography>
</Field> </Field>
<Field label="Vystavil">
<Typography variant="body2">
{invoice.issued_by || "—"}
</Typography>
</Field>
</Box> </Box>
{invoice.paid_date && ( {invoice.paid_date && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
@@ -1597,14 +1641,55 @@ export default function InvoiceDetail() {
</Box> </Box>
</Card> </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 }}> <Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Veřejné poznámky na faktuře Obsah
</Typography> </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 <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"> <Typography variant="body2" color="text.secondary">
@@ -1648,7 +1733,16 @@ export default function InvoiceDetail() {
mb: 3, 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 <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"
@@ -1659,7 +1753,6 @@ export default function InvoiceDetail() {
Zpět Zpět
</Button> </Button>
<Box> <Box>
{isEdit && invoice ? (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -1669,24 +1762,28 @@ export default function InvoiceDetail() {
}} }}
> >
<Typography variant="h4"> <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> </Typography>
{isEdit && invoice && (
<StatusChip <StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)} label={statusLabel(INVOICE_STATUS, invoice.status)}
color={statusColor(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> </Box>
{fromOrderId && ( {!isEdit && fromOrderId && (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
@@ -1695,8 +1792,6 @@ export default function InvoiceDetail() {
Z objednávky Z objednávky
</Typography> </Typography>
)} )}
</>
)}
</Box> </Box>
</Box> </Box>
<Box sx={headerActionsSx}> <Box sx={headerActionsSx}>
@@ -1866,20 +1961,10 @@ export default function InvoiceDetail() {
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje Základní údaje
</Typography> </Typography>
<Box {/* Číslo faktury and Vystavil are not form fields anymore: the
sx={{ number lives in the page header (deferred numbering — "Koncept"
display: "grid", until finalize) and issued_by is auto-filled from the logged-in
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, user and still submitted with the payload (the PDF prints it). */}
gap: 2,
}}
>
<Field label="Číslo faktury">
<TextField
value={invoiceNumber}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
<Field label="Odběratel" error={errors.customer_id} required> <Field label="Odběratel" error={errors.customer_id} required>
<CustomerPicker <CustomerPicker
customers={customers} customers={customers}
@@ -1889,14 +1974,6 @@ export default function InvoiceDetail() {
placeholder="Hledat zákazníka (název, IČ)..." placeholder="Hledat zákazníka (název, IČ)..."
/> />
</Field> </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)"> <Field label="Text fakturace (na PDF)">
<TextField <TextField
@@ -2242,16 +2319,26 @@ export default function InvoiceDetail() {
</Box> </Box>
</Card> </Card>
{/* Notes */} {/* Rich-text PDF sections — printed after the items (like orders) */}
<Card sx={{ mb: 3 }}> <SectionsEditor
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> sections={sections}
Veřejné poznámky na faktuře onChange={setSections}
</Typography> readOnly={false}
<RichEditor title="Obsah"
value={form.notes} language={form.language}
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
placeholder="Poznámky zobrazené na faktuře..."
/> />
{/* 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> </Card>
</form> </form>

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -10,18 +12,21 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
formatMultiCurrency, formatMultiCurrency,
czechPlural,
} from "../utils/formatters"; } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api"; import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import { import {
issuedOrderListOptions, issuedOrderListOptions,
issuedOrderStatsOptions, issuedOrderStatsOptions,
issuedOrderSuppliersOptions,
type IssuedOrder, type IssuedOrder,
} from "../lib/queries/issued-orders"; } from "../lib/queries/issued-orders";
import { import {
Button,
Card, Card,
DataTable, DataTable,
Pagination, Pagination,
@@ -44,9 +49,15 @@ import {
const API_BASE = "/api/admin"; 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, { const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
value: "", value: "",
label: "Všechny stavy", label: "Všechny",
}); });
const ViewIcon = ( const ViewIcon = (
@@ -62,6 +73,19 @@ const ViewIcon = (
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </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 = ( const PdfIcon = (
<svg <svg
width="18" width="18"
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Track first successful load so later refetches (filter/status/page change) // Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton. // keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false); 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<{ const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean; show: boolean;
order: IssuedOrder | null; order: IssuedOrder | null;
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
sort, sort,
order, order,
page, page,
perPage: 20,
status: status || undefined, status: status || undefined,
supplier_id: supplierFilter || undefined,
month, month,
year, year,
}), }),
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
issuedOrderStatsOptions({ issuedOrderStatsOptions({
search: debouncedSearch, search: debouncedSearch,
status: status || undefined, status: status || undefined,
supplier_id: supplierFilter || undefined,
month, month,
year, 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 // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/page change) keep the table visible (the Card // refetches (filter/status/page change) keep the table visible (the Card
// dims via isFetching) so it doesn't flash. // dims via isFetching) so it doesn't flash.
@@ -234,6 +242,14 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
width: "24%", width: "24%",
render: (o) => o.supplier_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", key: "status",
header: "Stav", header: "Stav",
@@ -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", key: "total",
header: "Celkem", header: "Celkem",
@@ -268,24 +276,36 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
header: "Akce", header: "Akce",
width: "14%", width: "14%",
align: "right", 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" }}> <Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton <IconButton
size="small" size="small"
onClick={() => navigate(`/orders/issued/${o.id}`)} onClick={() => navigate(`/orders/issued/${o.id}`)}
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"} aria-label={readOnly ? "Zobrazit" : "Upravit"}
title={hasPermission("orders.edit") ? "Upravit" : "Detail"} title={readOnly ? "Zobrazit" : "Upravit"}
> >
{ViewIcon} {readOnly ? ViewIcon : EditIcon}
</IconButton> </IconButton>
{hasPermission("orders.view") && ( {/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("orders.view") && o.po_number && (
<IconButton <IconButton
size="small" size="small"
onClick={() => handleExportPdf(o)} onClick={() =>
aria-label="PDF" openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
title="PDF" }
aria-label="Zobrazit objednávku"
title="Zobrazit objednávku"
disabled={pdfLoadingId === o.id}
> >
{PdfIcon} {pdfLoadingId === o.id ? (
<CircularProgress size={16} />
) : (
PdfIcon
)}
</IconButton> </IconButton>
)} )}
{hasPermission("orders.delete") && ( {hasPermission("orders.delete") && (
@@ -300,7 +320,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
</IconButton> </IconButton>
)} )}
</Box> </Box>
), );
},
}, },
]; ];
@@ -326,12 +347,25 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
return {}; return {};
}; };
// Search/status filter is active → an empty list means "nothing matches" // Search/status/supplier filter is active → an empty list means "nothing
// (no create CTA); a genuinely empty list keeps the create-CTA empty state. // matches" (no create CTA); a genuinely empty list keeps the create-CTA
const isFiltered = !!debouncedSearch || !!status; // 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 ( return (
<> <>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{countLine}
</Typography>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
<TextField <TextField
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
options={STATUS_OPTIONS} options={STATUS_OPTIONS}
/> />
</Box> </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> </FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
onSort={handleSort} onSort={handleSort}
empty={ empty={
isFiltered ? ( 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 <EmptyState
title="Zatím žádné vydané objednávky." title="Zatím žádné vydané objednávky."
description={ action={
hasPermission("orders.create") hasPermission("orders.create") ? (
? "Vytvořte první tlačítkem „Vytvořit objednávku." <Button component={RouterLink} to="/orders/issued/new">
: undefined Vytvořit objednávku
</Button>
) : undefined
} }
/> />
) )
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
title="Smazat objednávku" title="Smazat objednávku"
message={ message={
deleteConfirm.order 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" confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />

File diff suppressed because it is too large Load Diff

View File

@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import { import {
offerListOptions, offerListOptions,
offerStatsOptions, offerStatsOptions,
offerCustomersOptions, offerCustomersOptions,
type Quotation,
} from "../lib/queries/offers"; } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { import {
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
label: "Všechny", label: "Všechny",
}).filter((o) => o.value !== "expired"); }).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 = ( const TemplatesIcon = (
<svg <svg
width="20" width="20"
@@ -336,25 +324,17 @@ export default function Offers() {
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [deleting, setDeleting] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState<{ const [invalidateConfirm, setInvalidateConfirm] = useState<{
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false); 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 [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = 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<{ const [orderModal, setOrderModal] = useState<{
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
@@ -490,13 +470,10 @@ export default function Offers() {
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteConfirm.quotation) return; if (!deleteConfirm.quotation) return;
setDeleting(true);
try { try {
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id); await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
} catch (e) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); 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 // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the // refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash. // Card dims via isFetching) so it doesn't flash.
@@ -573,6 +519,7 @@ export default function Offers() {
header: "Číslo", header: "Číslo",
width: "13%", width: "13%",
sortKey: "quotation_number", sortKey: "quotation_number",
mono: true,
render: (q) => ( render: (q) => (
<Box <Box
component={RouterLink} component={RouterLink}
@@ -597,7 +544,7 @@ export default function Offers() {
{ {
key: "customer_name", key: "customer_name",
header: "Zákazník", header: "Zákazník",
width: "13%", width: "16%",
render: (q) => q.customer_name || "—", 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", key: "total",
header: "Celkem", header: "Celkem",
width: "11%", width: "12%",
align: "right", align: "right",
mono: true, mono: true,
bold: true, bold: true,
@@ -693,7 +633,9 @@ export default function Offers() {
{OrderViewIcon} {OrderViewIcon}
</IconButton> </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") && ( hasPermission("orders.create") && (
<IconButton <IconButton
size="small" size="small"
@@ -714,15 +656,20 @@ export default function Offers() {
</IconButton> </IconButton>
) )
)} )}
{hasPermission("offers.view") && ( {/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("offers.view") && q.quotation_number && (
<IconButton <IconButton
size="small" size="small"
onClick={() => handlePdf(q)} onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
aria-label="Zobrazit nabídku" aria-label="Zobrazit nabídku"
title="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> </IconButton>
)} )}
{hasPermission("offers.delete") && ( {hasPermission("offers.delete") && (
@@ -927,10 +874,10 @@ export default function Offers() {
onClose={() => setDeleteConfirm({ show: false, quotation: null })} onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat nabídku" 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" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
loading={deleting} loading={deleteOfferMutation.isPending}
/> />
<ConfirmDialog <ConfirmDialog
@@ -938,7 +885,7 @@ export default function Offers() {
onClose={() => setInvalidateConfirm({ show: false, quotation: null })} onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate} onConfirm={handleInvalidate}
title="Zneplatnit nabídku" 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" confirmText="Zneplatnit"
confirmVariant="danger" confirmVariant="danger"
loading={invalidating} loading={invalidating}

View File

@@ -12,6 +12,7 @@ import {
type CustomField, type CustomField,
} from "../lib/queries/offers"; } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import { import {
Button, Button,
Card, Card,
@@ -198,7 +199,8 @@ export default function OffersCustomers() {
? `${API_BASE}/customers/${editingCustomer.id}` ? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`, : `${API_BASE}/customers`,
method: () => (editingCustomer ? "PUT" : "POST"), method: () => (editingCustomer ? "PUT" : "POST"),
invalidate: ["offer-customers", "offers"], // Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => { onSuccess: (data) => {
closeModal(); closeModal();
setTimeout( setTimeout(
@@ -214,7 +216,8 @@ export default function OffersCustomers() {
>({ >({
url: (id) => `${API_BASE}/customers/${id}`, url: (id) => `${API_BASE}/customers/${id}`,
method: () => "DELETE", method: () => "DELETE",
invalidate: ["offer-customers", "offers"], // Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => { onSuccess: (data) => {
setDeleteConfirm({ show: false, customer: null }); setDeleteConfirm({ show: false, customer: null });
alert.success(data?.message || "Zákazník byl smazán"); alert.success(data?.message || "Zákazník byl smazán");
@@ -265,6 +268,21 @@ export default function OffersCustomers() {
return key; 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 = () => { const openCreateModal = () => {
setEditingCustomer(null); setEditingCustomer(null);
setForm({ setForm({
@@ -533,6 +551,15 @@ export default function OffersCustomers() {
setForm((prev) => ({ ...prev, name: e.target.value })) setForm((prev) => ({ ...prev, name: e.target.value }))
} }
placeholder="Název firmy / jméno" placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="Ulice"> <Field label="Ulice">
@@ -594,6 +621,15 @@ export default function OffersCustomers() {
company_id: e.target.value, company_id: e.target.value,
})) }))
} }
InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.company_id}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="DIČ"> <Field label="DIČ">

View File

@@ -34,6 +34,8 @@ import {
CheckboxField, CheckboxField,
LoadingState, LoadingState,
PageEnter, PageEnter,
Tabs,
type TabDef,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -54,6 +56,12 @@ const STATUS_COLORS: Record<
zruseny: "default", 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 { interface Project {
id: number; id: number;
project_number: string; project_number: string;
@@ -120,6 +128,7 @@ export default function Projects() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null); const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
@@ -230,7 +239,13 @@ export default function Projects() {
isPending, isPending,
isFetching, isFetching,
} = usePaginatedQuery<Project>( } = usePaginatedQuery<Project>(
projectListOptions({ search: debouncedSearch, sort, order, page }), projectListOptions({
search: debouncedSearch,
sort,
order,
page,
status: statusFilter || undefined,
}),
); );
if (!hasPermission("projects.view")) return <Forbidden />; if (!hasPermission("projects.view")) return <Forbidden />;
@@ -372,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 ( return (
<PageEnter> <PageEnter>
<Box <Box
@@ -392,6 +432,17 @@ export default function Projects() {
)} )}
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={STATUS_TABS}
/>
</Box>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
<TextField <TextField
@@ -411,10 +462,21 @@ export default function Projects() {
columns={columns} columns={columns}
rows={projects} rows={projects}
rowKey={(p) => p.id} rowKey={(p) => p.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ 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 }}> <Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom> <Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty. Zatím nejsou žádné projekty.
@@ -424,6 +486,7 @@ export default function Projects() {
tlačítkem Přidat projekt". tlačítkem Přidat projekt".
</Typography> </Typography>
</Box> </Box>
)
} }
/> />
<Pagination <Pagination

View File

@@ -1,15 +1,17 @@
import { useState } from "react"; import { useState, useRef } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { import {
warehouseSupplierListOptions, warehouseSupplierListOptions,
type WarehouseSupplier, type WarehouseSupplier,
type WarehouseSupplierCustomField,
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { import {
@@ -21,6 +23,7 @@ import {
ConfirmDialog, ConfirmDialog,
Field, Field,
TextField, TextField,
CheckboxField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter, PageEnter,
@@ -32,17 +35,29 @@ import {
const API_BASE = "/api/admin/warehouse/suppliers"; 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 { interface SupplierForm {
name: string; name: string;
ico: string; ico: string;
dic: string; dic: string;
contact_person: string; street: string;
email: string; city: string;
phone: string; postal_code: string;
address: string; country: string;
notes: string;
} }
const EMPTY_SUPPLIER_FORM: SupplierForm = {
name: "",
ico: "",
dic: "",
street: "",
city: "",
postal_code: "",
country: "",
};
const PER_PAGE = 20; const PER_PAGE = 20;
const PlusIcon = ( 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" /> <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> </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() { export default function WarehouseSuppliers() {
const alert = useAlert(); const alert = useAlert();
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] = const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null); useState<WarehouseSupplier | null>(null);
const [form, setForm] = useState<SupplierForm>({ const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
name: "", const [customFields, setCustomFields] = useState<
ico: "", WarehouseSupplierCustomField[]
dic: "", >([]);
contact_person: "", const customFieldKeyCounter = useRef(0);
email: "",
phone: "",
address: "",
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [deactivateConfirm, setDeactivateConfirm] = useState<{ const [deactivateConfirm, setDeactivateConfirm] = useState<{
@@ -131,7 +167,13 @@ export default function WarehouseSuppliers() {
}>({ show: false, supplier: null }); }>({ show: false, supplier: null });
const submitMutation = useApiMutation< const submitMutation = useApiMutation<
SupplierForm, SupplierForm & {
custom_fields: Array<{
name: string;
value: string;
showLabel?: boolean;
}>;
},
{ id?: number; message?: string } { id?: number; message?: string }
>({ >({
url: () => url: () =>
@@ -174,18 +216,26 @@ export default function WarehouseSuppliers() {
if (!hasPermission("warehouse.manage")) return <Forbidden />; 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 = () => { const openCreateModal = () => {
setEditingSupplier(null); setEditingSupplier(null);
setForm({ setForm({ ...EMPTY_SUPPLIER_FORM });
name: "", setCustomFields([]);
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
setErrors({}); setErrors({});
setShowModal(true); setShowModal(true);
}; };
@@ -196,12 +246,19 @@ export default function WarehouseSuppliers() {
name: supplier.name, name: supplier.name,
ico: supplier.ico || "", ico: supplier.ico || "",
dic: supplier.dic || "", dic: supplier.dic || "",
contact_person: supplier.contact_person || "", street: supplier.street || "",
email: supplier.email || "", city: supplier.city || "",
phone: supplier.phone || "", postal_code: supplier.postal_code || "",
address: supplier.address || "", country: supplier.country || "",
notes: supplier.notes || "",
}); });
setCustomFields(
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
? supplier.custom_fields.map((f) => ({
...f,
_key: `cf-${++customFieldKeyCounter.current}`,
}))
: [],
);
setErrors({}); setErrors({});
setShowModal(true); setShowModal(true);
}; };
@@ -213,7 +270,16 @@ export default function WarehouseSuppliers() {
if (Object.keys(newErrors).length > 0) return; if (Object.keys(newErrors).length > 0) return;
try { 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) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); alert.error(e instanceof Error ? e.message : "Chyba připojení");
} }
@@ -281,23 +347,16 @@ export default function WarehouseSuppliers() {
render: (s) => s.dic || "—", render: (s) => s.dic || "—",
}, },
{ {
key: "contact_person", key: "street",
header: "Kontaktní osoba", header: "Ulice",
width: "16%", width: "18%",
render: (s) => s.contact_person || "—", render: (s) => s.street || "—",
}, },
{ {
key: "email", key: "city",
header: "E-mail", header: "Město",
width: "16%", width: "14%",
render: (s) => s.email || "—", render: (s) => s.city || "—",
},
{
key: "phone",
header: "Telefon",
width: "12%",
mono: true,
render: (s) => s.phone || "—",
}, },
{ {
key: "status", key: "status",
@@ -394,13 +453,15 @@ export default function WarehouseSuppliers() {
/> />
</Card> </Card>
{/* Add/Edit Modal */} {/* Add/Edit Modal — mirror of the customers modal (minus the PDF
field-order picker) */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"} title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={submitMutation.isPending} loading={submitMutation.isPending}
maxWidth="md"
> >
<Field label="Název" required error={errors.name}> <Field label="Název" required error={errors.name}>
<TextField <TextField
@@ -410,7 +471,53 @@ export default function WarehouseSuppliers() {
setForm({ ...form, name: e.target.value }); setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" })); 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> </Field>
@@ -425,72 +532,129 @@ export default function WarehouseSuppliers() {
<TextField <TextField
value={form.ico} value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })} onChange={(e) => setForm({ ...form, ico: e.target.value })}
placeholder="12345678" InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.ico}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="DIČ"> <Field label="DIČ">
<TextField <TextField
value={form.dic} value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })} onChange={(e) => setForm({ ...form, dic: e.target.value })}
placeholder="CZ12345678"
/> />
</Field> </Field>
</Box> </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 <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2, gap: 2,
alignItems: "flex-start",
}} }}
> >
<Field label="Kontaktní osoba">
<TextField <TextField
value={form.contact_person} label={idx === 0 ? "Název" : undefined}
onChange={(e) => value={field.name}
setForm({ ...form, contact_person: e.target.value }) 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" title="Odebrat pole"
/> aria-label="Odebrat pole"
</Field> >
<Field label="E-mail"> {RemoveIcon}
<TextField </IconButton>
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="info@firma.cz"
/>
</Field>
</Box> </Box>
</Box>
<Field label="Telefon"> <Box sx={{ mt: 0.5 }}>
<TextField <CheckboxField
type="tel" label={
value={form.phone} <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
onChange={(e) => setForm({ ...form, phone: e.target.value })} Zobrazit název v PDF
placeholder="+420 123 456 789" </Typography>
}
checked={field.showLabel !== false}
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], showLabel: checked };
setCustomFields(updated);
}}
/> />
</Field> </Box>
</Box>
<Field label="Adresa"> ))}
<TextField <Button
multiline variant="outlined"
minRows={3} color="inherit"
value={form.address} size="small"
onChange={(e) => setForm({ ...form, address: e.target.value })} startIcon={SmallPlusIcon}
placeholder="Ulice, město, PSČ" onClick={() =>
/> setCustomFields([
</Field> ...customFields,
{
<Field label="Poznámky"> name: "",
<TextField value: "",
multiline showLabel: true,
minRows={3} _key: `cf-${++customFieldKeyCounter.current}`,
value={form.notes} },
onChange={(e) => setForm({ ...form, notes: e.target.value })} ])
placeholder="Volitelné poznámky" }
/> sx={{ mt: 0.5 }}
</Field> >
Přidat pole
</Button>
</Box>
</Modal> </Modal>
{/* Deactivate/Delete Confirmation */} {/* Deactivate/Delete Confirmation */}

View File

@@ -58,7 +58,13 @@ export const theme = createTheme({
h1: { fontFamily: FONT_HEADING, fontWeight: 800 }, h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 }, h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { 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 }, h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 }, h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 }, button: { textTransform: "none", fontWeight: 600 },

View File

@@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
// Deploy-skew recovery (official Vite mechanism): after a release the old
// hashed chunks are deleted, so a tab still running the previous build fails
// its next lazy-page import. Vite emits `vite:preloadError` for exactly this —
// swallow the error and reload once, which loads the fresh index.html and new
// chunk names. The timestamp guard prevents a reload loop when a chunk is
// genuinely unloadable (e.g. offline): a second failure within 10 s falls
// through to the normal error path.
window.addEventListener("vite:preloadError", (event) => {
const KEY = "vite-preload-reloaded-at";
const last = Number(sessionStorage.getItem(KEY) || 0);
if (Date.now() - last < 10_000) return;
sessionStorage.setItem(KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
});
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter <BrowserRouter

63
src/routes/admin/ares.ts Normal file
View 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);
},
);
}

View File

@@ -9,48 +9,20 @@ import {
CreateCustomerSchema, CreateCustomerSchema,
UpdateCustomerSchema, UpdateCustomerSchema,
} from "../../schemas/customers.schema"; } from "../../schemas/customers.schema";
import {
encodeCustomFields,
decodeCustomFields as decodeCustomFieldsShared,
} from "../../utils/custom-fields";
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"]; const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */ /** Customer-shaped wrapper over the shared codec (field_order key naming). */
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 */
function decodeCustomFields(raw: string | null): { function decodeCustomFields(raw: string | null): {
custom_fields: unknown[]; custom_fields: unknown[];
customer_field_order: string[]; customer_field_order: string[];
} { } {
if (!raw) return { custom_fields: [], customer_field_order: [] }; const { custom_fields, field_order } = decodeCustomFieldsShared(raw);
try { return { custom_fields, customer_field_order: field_order };
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: [] };
}
} }
export default async function customersRoutes( export default async function customersRoutes(

View File

@@ -2,12 +2,20 @@ import { FastifyInstance } from "fastify";
import QRCode from "qrcode"; import QRCode from "qrcode";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates"; import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date"; import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
@@ -16,62 +24,6 @@ const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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 { interface AddressResult {
name: string; name: string;
lines: 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 ───────────────────────────────────────────────────────── */ /* ── Route ───────────────────────────────────────────────────────── */
export default async function invoicesPdfRoutes( export default async function invoicesPdfRoutes(
@@ -314,6 +307,11 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id }, where: { invoice_id: id },
orderBy: { position: "asc" }, 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; let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) { if (invoice.customer_id) {
@@ -350,16 +348,12 @@ export default async function invoicesPdfRoutes(
} }
} }
let logoImg = ""; // Logo as a data URI — used by the body header (screen/HTML view) AND
if (settings?.logo_data) { // the Puppeteer headerTemplate (templates can only load data: URLs).
const buf = Buffer.from(settings.logo_data as Buffer); const logoDataUri = logoDataUriFromSettings(settings);
let mime = "image/png"; const logoImg = logoDataUri
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; ? `<img src="${logoDataUri}" class="logo" />`
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" />`;
}
const currency = invoice.currency || "CZK"; const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat; const applyVat = !!invoice.apply_vat;
@@ -522,26 +516,44 @@ export default async function invoicesPdfRoutes(
} }
} }
const notesRaw = invoice.notes ?? ""; // ── Sections ("Obsah") — flow INLINE right after the items/totals
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); // block (mirrors issued orders): no separate page, long content
const notesHtml = notesStripped // paginates naturally and the Puppeteer footer template stamps every
? ` // page. Suppressed entirely when every section is empty (tag-stripped
<!-- Poznamky --> // check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
<div class="invoice-notes"> // the Czech title when present; en (or a missing Czech title) falls
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> // back to the EN title.
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div> const resolveSectionTitle = (s: {
</div> 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 // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
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 html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">
@@ -552,7 +564,11 @@ export default async function invoicesPdfRoutes(
<style> <style>
@page { @page {
size: A4; 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; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body {
@@ -565,11 +581,19 @@ export default async function invoicesPdfRoutes(
.invoice-page { .invoice-page {
display: flex; display: flex;
flex-direction: column; 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; } .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 { .invoice-footer {
flex-shrink: 0; flex-shrink: 0;
break-inside: avoid;
} }
.accent { color: #de3a3a; } .accent { color: #de3a3a; }
@@ -781,13 +805,8 @@ export default async function invoicesPdfRoutes(
margin-top: 2mm; margin-top: 2mm;
} }
/* Vystavil */ /* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
.issued-by { every page — same as the issued-order PDF; no body block. */
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */ /* Upozorneni */
.notice { .notice {
@@ -853,32 +872,42 @@ export default async function invoicesPdfRoutes(
color: #555; color: #555;
} }
/* Poznamky */ /* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
.invoice-notes { dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
margin-top: 4mm; stranu (mirrors issued-orders-pdf). */
font-size: 10pt; .scope-sections {
line-height: 1.5; margin-top: 6mm;
color: #1a1a1a;
} }
.invoice-notes-label { .scope-section {
font-weight: 600; width: 100%;
font-size: 9pt; max-width: 100%;
text-transform: uppercase; margin-bottom: 3mm;
color: #555; break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm; margin-bottom: 1mm;
} }
.invoice-notes-content p { margin: 0 0 0.4em 0; } .section-content {
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } color: #1a1a1a;
.invoice-notes-content li { margin-bottom: 0.2em; } line-height: 1.5;
.invoice-notes-content, word-break: normal;
.invoice-notes-content * { overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important; font-family: Tahoma, sans-serif !important;
} }
.invoice-notes-content { font-size: 14px; } .section-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; } .section-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; } .section-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; } .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 */ /* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -889,6 +918,10 @@ ${indentCSS}
@media print { @media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } 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 { @media screen {
html { background: #525659; } html { background: #525659; }
@@ -1000,16 +1033,12 @@ ${indentCSS}
</div> </div>
</div> </div>
${notesHtml} ${sectionsHtml}
</div><!-- /.invoice-content --> </div><!-- /.invoice-content -->
<div class="invoice-footer"> <div class="invoice-footer">
<!-- Vystavil --> <!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ""}
</div>
<!-- Upozorneni --> <!-- Upozorneni -->
<div class="notice"> <div class="notice">
@@ -1074,7 +1103,20 @@ ${indentCSS}
? new Date(invoice.issue_date) ? new Date(invoice.issue_date)
: new Date(); : new Date();
nasInvoicesManager.cleanIssued(invoice.invoice_number!); 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( nasInvoicesManager.saveIssuedPdf(
invoice.invoice_number!, invoice.invoice_number!,
issueDate.getFullYear(), issueDate.getFullYear(),

View File

@@ -2,65 +2,25 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager"; 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 createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
const window = new JSDOM("").window; const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */ /* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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 { interface AddressResult {
name: string; name: string;
@@ -157,11 +117,12 @@ function buildAddressLines(
} }
/** /**
* Address block for the sklad_suppliers counterparty. Unlike customers (which * Address block for the sklad_suppliers counterparty — full customer model
* have structured street/city/postal columns), suppliers.address is a single * (structured street/city/postal_code/country + custom_fields JSON; the old
* Text blob — split it on newlines into one rendered line each (a blob without * dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic * the supplier's ico/dic columns, prefixed with the same translated labels
* columns, prefixed with the same translated labels the customer block used. * the customer block uses; custom fields render as appended lines in array
* order (suppliers have no PDF field-order picker).
*/ */
function buildSupplierLines( function buildSupplierLines(
supplier: Record<string, unknown> | null, supplier: Record<string, unknown> | null,
@@ -170,14 +131,52 @@ function buildSupplierLines(
if (!supplier) return { name: "", lines: [] }; if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || ""); const name = String(supplier.name || "");
const lines: string[] = []; const lines: string[] = [];
if (supplier.address) { if (supplier.street) lines.push(String(supplier.street));
for (const part of String(supplier.address).split(/\r?\n/)) { const cityLine = [supplier.postal_code, supplier.city]
const line = part.trim(); .filter(Boolean)
if (line) lines.push(line); .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.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`); 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 }; return { name, lines };
} }
@@ -202,10 +201,6 @@ const translations: Record<Lang, Record<string, string>> = {
col_unit_price: "Jedn. cena", col_unit_price: "Jedn. cena",
col_total: "Celkem", col_total: "Celkem",
total_no_vat: "Celkem bez DPH", total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
payment_terms: "Platební podmínky:",
issued_by: "Vystavil:", issued_by: "Vystavil:",
ico: "IČ: ", ico: "IČ: ",
dic: "DIČ: ", dic: "DIČ: ",
@@ -224,10 +219,6 @@ const translations: Record<Lang, Record<string, string>> = {
col_unit_price: "Unit price", col_unit_price: "Unit price",
col_total: "Total", col_total: "Total",
total_no_vat: "Total excl. VAT", total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
issued_by: "Issued by:", issued_by: "Issued by:",
ico: "Reg. No.: ", ico: "Reg. No.: ",
dic: "Tax ID: ", dic: "Tax ID: ",
@@ -239,10 +230,6 @@ interface IssuedOrderPdfData {
order_date: Date | null; order_date: Date | null;
delivery_date: Date | null; delivery_date: Date | null;
currency: string | null; currency: string | null;
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. // Editable heading above the items table; null → t.billing default.
order_text?: string | null; order_text?: string | null;
} }
@@ -255,29 +242,74 @@ interface IssuedOrderPdfItem {
unit_price: unknown; unit_price: 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( export function renderIssuedOrderHtml(
order: IssuedOrderPdfData, order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[], items: IssuedOrderPdfItem[],
supplier: Record<string, unknown> | null, supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null, settings: Record<string, unknown> | null,
lang: Lang, 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 { ): string {
const t = translations[lang]; const t = translations[lang];
const currency = order.currency || "CZK"; const currency = order.currency || "CZK";
const poNumber = escapeHtml(order.po_number || ""); const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template). // Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
let logoImg = ""; const logoDataUri = logoDataUriFromSettings(settings);
if (settings?.logo_data) { const logoImg = logoDataUri
const buf = Buffer.from(settings.logo_data as Buffer); ? `<img src="${logoDataUri}" class="logo" />`
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" />`;
}
// PO direction: our company (settings) = Odběratel (buyer); // PO direction: our company (settings) = Odběratel (buyer);
// the sklad_suppliers record = Dodavatel (supplier). // the sklad_suppliers record = Dodavatel (supplier).
@@ -311,45 +343,53 @@ export function renderIssuedOrderHtml(
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td> <td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td> <td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td> <td class="right">${formatCurrency(unitPrice, currency)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td> <td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
total = Math.round(total * 100) / 100; total = Math.round(total * 100) / 100;
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>
`
: "";
const issueDateStr = formatDate(order.order_date); const issueDateStr = formatDate(order.order_date);
const deliveryDateStr = formatDate(order.delivery_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 // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
for (let n = 1; n <= 9; n++) {
const pad = n * 3; // ── Sections ("Obsah") — flow INLINE after the items/totals block (user
const liPad = n * 3 + 1.5; // decision: no separate page, no repeated header). Each section keeps
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; // break-inside: avoid; long content paginates naturally and the Puppeteer
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; // 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> return `<!DOCTYPE html>
@@ -360,7 +400,11 @@ export function renderIssuedOrderHtml(
<style> <style>
@page { @page {
size: A4; 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; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body {
@@ -370,15 +414,9 @@ export function renderIssuedOrderHtml(
width: 186mm; width: 186mm;
} }
.invoice-page { /* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
display: flex; footerTemplate (bottom page margin), NOT in the body — the old flex
flex-direction: column; min-height pinning is gone with it. */
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; } .accent { color: #de3a3a; }
@@ -583,34 +621,6 @@ export function renderIssuedOrderHtml(
border-bottom: 2.5pt solid #de3a3a; border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm; 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 */ /* Upozorneni */
.notice { .notice {
font-size: 8pt; font-size: 8pt;
@@ -619,32 +629,42 @@ export function renderIssuedOrderHtml(
line-height: 1.3; line-height: 1.3;
} }
/* Poznamky */ /* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
.invoice-notes { dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
margin-top: 4mm; stranu. */
font-size: 10pt; .scope-sections {
line-height: 1.5; margin-top: 6mm;
color: #1a1a1a;
} }
.invoice-notes-label { .scope-section {
font-weight: 600; width: 100%;
font-size: 9pt; max-width: 100%;
text-transform: uppercase; margin-bottom: 3mm;
color: #555; break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm; margin-bottom: 1mm;
} }
.invoice-notes-content p { margin: 0 0 0.4em 0; } .section-content {
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } color: #1a1a1a;
.invoice-notes-content li { margin-bottom: 0.2em; } line-height: 1.5;
.invoice-notes-content, word-break: normal;
.invoice-notes-content * { overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important; font-family: Tahoma, sans-serif !important;
} }
.invoice-notes-content { font-size: 14px; } .section-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; } .section-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; } .section-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; } .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 */ /* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -655,6 +675,10 @@ ${indentCSS}
@media print { @media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } 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 { @media screen {
html { background: #525659; } html { background: #525659; }
@@ -666,6 +690,7 @@ ${indentCSS}
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 30px;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -733,28 +758,21 @@ ${indentCSS}
</tbody> </tbody>
</table> </table>
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) --> <!-- 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-wrapper">
<div class="totals"> <div class="totals">
<div class="grand"> <div class="grand">
<span class="label">${escapeHtml(t.total_no_vat)}</span> <span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span> <span class="value">${formatCurrency(total, currency)}</span>
</div> </div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div> </div>
</div> </div>
${notesHtml} ${scopeHtml}
${termsBlock}
</div><!-- /.invoice-content --> </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 --> </div><!-- /.invoice-page -->
</body> </body>
@@ -773,7 +791,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const lang: Lang = query.lang === "en" ? "en" : "cs";
try { try {
const order = await prisma.issued_orders.findUnique({ where: { id } }); const order = await prisma.issued_orders.findUnique({ where: { id } });
@@ -783,10 +800,23 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
.type("text/html") .type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></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({ const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id }, where: { issued_order_id: id },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
}); });
// 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 const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({ ? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id }, where: { id: order.supplier_id },
@@ -811,6 +841,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
settings, settings,
lang, lang,
issuer, issuer,
sections,
); );
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors // ?save=1 → archive the PDF to NAS instead of returning it (mirrors
@@ -821,7 +852,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
? new Date(order.order_date) ? new Date(order.order_date)
: new Date(); : new Date();
nasOrdersManager.cleanIssued(order.po_number); 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( nasOrdersManager.saveIssuedPdf(
order.po_number, order.po_number,
baseDate.getFullYear(), baseDate.getFullYear(),
@@ -831,7 +869,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
return success(reply, null, 200, "PDF uloženo"); 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`; const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
return reply return reply
.type("application/pdf") .type("application/pdf")

View File

@@ -18,6 +18,20 @@ import {
deleteIssuedOrder, deleteIssuedOrder,
getNextIssuedOrderNumberPreview, getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service"; } 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) { export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
// GET /api/admin/issued-orders // GET /api/admin/issued-orders
@@ -26,12 +40,12 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, unknown>; 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({ const result = await listIssuedOrders({
page, page,
limit, limit,
skip, skip,
sort: String(query.sort || ""), sort,
order, order,
search, search,
status: query.status ? String(query.status) : undefined, status: query.status ? String(query.status) : undefined,
@@ -56,9 +70,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
}, },
); );
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed // GET /api/admin/issued-orders/stats — per-currency NET total summed over
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the // the WHOLE filtered set (not one page). Issued orders are not tax documents
// literal "stats" path isn't captured as an order id. // — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
// isn't captured as an order id.
fastify.get( fastify.get(
"/stats", "/stats",
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
@@ -98,9 +113,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
name: true, name: true,
ico: true, ico: true,
dic: true, dic: true,
address: true, street: true,
email: true, city: true,
phone: true, postal_code: true,
country: true,
}, },
// id tiebreak so same-name suppliers sort deterministically. // id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }], orderBy: [{ name: "asc" }, { id: "asc" }],
@@ -118,7 +134,222 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if (id === null) return; if (id === null) return;
const order = await getIssuedOrder(id); const order = await getIssuedOrder(id);
if (!order) return error(reply, "Objednávka nenalezena", 404); 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);
}
}, },
); );
@@ -133,6 +364,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in order) { if ("error" in order) {
if (order.error === "supplier_not_found") if (order.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400); 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); return error(reply, "Neznámá chyba", 500);
} }
await logAudit({ await logAudit({
@@ -165,6 +398,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in result) { if ("error" in result) {
if (result.error === "not_found") if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404); 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") if (result.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400); return error(reply, "Dodavatel nenalezen", 400);
if (result.error === "invalid_transition") if (result.error === "invalid_transition")
@@ -181,9 +416,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "update", action: "update",
entityType: "issued_order", entityType: "issued_order",
entityId: id, 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",
);
}, },
); );
@@ -202,7 +446,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "delete", action: "delete",
entityType: "issued_order", entityType: "issued_order",
entityId: id, 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"); return success(reply, null, 200, "Objednávka smazána");
}, },

View File

@@ -1,102 +1,24 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import type { Prisma, company_settings } from "@prisma/client";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager"; import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
const window = new JSDOM("").window; const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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 { interface AddressResult {
name: string; name: string;
lines: string[]; lines: string[];
@@ -210,35 +132,24 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
of: { EN: "of", CZ: "z" }, of: { EN: "of", CZ: "z" },
}; };
export default async function offersPdfRoutes( /** Quotation row with the relations the PDF template renders. */
fastify: FastifyInstance, export type OfferForPdf = Prisma.quotationsGetPayload<{
): 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: { include: {
customers: true, customers: true;
quotation_items: { orderBy: { position: "asc" } }, quotation_items: true;
scope_sections: { orderBy: { position: "asc" } }, scope_sections: true;
}, };
}); }>;
if (!quotation) { /**
return reply * Build the full offer PDF/preview HTML. Extracted from the route handler so
.status(404) * the offers /:id/file live-render fallback (quotations.ts) can reuse the
.type("text/html") * exact same template without going through HTTP.
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>"); */
} export function renderOfferHtml(
quotation: OfferForPdf,
const settings = await prisma.company_settings.findFirst(); settings: company_settings | null,
): string {
const isCzech = (quotation.language ?? "EN") !== "EN"; const isCzech = (quotation.language ?? "EN") !== "EN";
const langKey = isCzech ? "CZ" : "EN"; const langKey = isCzech ? "CZ" : "EN";
const currency = quotation.currency || "EUR"; const currency = quotation.currency || "EUR";
@@ -259,17 +170,28 @@ export default async function offersPdfRoutes(
let total = 0; let total = 0;
for (const item of items) { for (const item of items) {
if (item.is_included_in_total !== false) { if (item.is_included_in_total !== false) {
total += total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
hasScopeContent = true;
break;
} }
} }
// 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(/&nbsp;/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( const cust = buildAddressLines(
quotation.customers as unknown as Record<string, unknown>, quotation.customers as unknown as Record<string, unknown>,
@@ -290,24 +212,21 @@ export default async function offersPdfRoutes(
.join(""); .join("");
// Indentation CSS for Quill // Indentation CSS for Quill
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
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`;
}
let itemsHtml = ""; let itemsHtml = "";
items.forEach((item, i) => { items.forEach((item, i) => {
const lineTotal = const qty = Number(item.quantity) || 0;
(Number(item.quantity) || 0) * (Number(item.unit_price) || 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 subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : ""; const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}> itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</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">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td> <td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`; </tr>`;
@@ -334,15 +253,11 @@ export default async function offersPdfRoutes(
</div> </div>
<hr class="separator" />`; <hr class="separator" />`;
for (const section of quotation.scope_sections) { for (const section of visibleSections) {
const title = const title = resolveSectionTitle(section);
isCzech && (section.title_cz || "").trim()
? section.title_cz
: section.title || "";
const content = (section.content || "").trim(); const content = (section.content || "").trim();
if (!title && !content) continue;
scopeHtml += '<div class="scope-section">'; scopeHtml += '<div class="scope-section">';
if (title) if (title.trim())
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`; scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content) if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`; scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
@@ -732,6 +647,40 @@ ${indentCSS}
</body> </body>
</html>`; </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"; const saveMode = query.save === "1";
// Save PDF to NAS // Save PDF to NAS

View File

@@ -1,9 +1,15 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, error } from "../../utils/response"; import { parseId, error } from "../../utils/response";
import {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { z } from "zod"; import { z } from "zod";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
@@ -32,55 +38,6 @@ const OrderPdfBodySchema = z.looseObject({
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/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 { interface AddressResult {
name: string; name: string;
lines: string[]; lines: string[];
@@ -249,9 +206,8 @@ interface OrderConfirmationPdfData {
/** /**
* Order-confirmation HTML. The confirmation is NOT a tax document — prices * 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 * are NET only: no VAT columns or VAT summary; the grand total is labeled
* "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT * "Celkem bez DPH". Exported for tests.
* notice. Exported for tests.
*/ */
export function renderOrderConfirmationHtml( export function renderOrderConfirmationHtml(
order: OrderConfirmationPdfData, order: OrderConfirmationPdfData,
@@ -330,13 +286,7 @@ export function renderOrderConfirmationHtml(
: ""; : "";
// Quill indent CSS // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
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`;
}
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">

View File

@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { contentDisposition } from "../../utils/content-disposition";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
CreateQuotationSchema, CreateQuotationSchema,
@@ -21,8 +22,13 @@ import {
getNextOfferNumber, getNextOfferNumber,
} from "../../services/offers.service"; } from "../../services/offers.service";
import { nasOffersManager } from "../../services/nas-offers-manager"; 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( export default async function quotationsRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -53,9 +59,10 @@ export default async function quotationsRoutes(
}, },
); );
// GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the // GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
// WHOLE filtered set (not one page). Offers have NO month/year filter; the // filtered set (not one page). Offers are not tax documents — no VAT
// list filters by search + status + customer_id, so the totals use the same. // 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. // Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
fastify.get( fastify.get(
"/stats", "/stats",
@@ -117,8 +124,19 @@ export default async function quotationsRoutes(
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await invalidateOffer(id); const result = await invalidateOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404); 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({ await logAudit({
request, request,
@@ -126,7 +144,9 @@ export default async function quotationsRoutes(
action: "update", action: "update",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); 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); if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data); const quotation = await createOffer(parsed.data);
if ("error" in quotation) if ("error" in quotation) {
return error(reply, quotation.error!, quotation.status!); 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({ await logAudit({
request, request,
@@ -295,13 +320,17 @@ export default async function quotationsRoutes(
if ("error" in result) { if ("error" in result) {
if (result.error === "not_found") if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404); return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalidated") if (result.error === "not_editable")
return error(reply, "Nelze upravit zneplatněnou nabídku", 400); return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
if (result.error === "invalid_transition")
return error( return error(
reply, reply,
result.error || "Neznámá chyba", `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
"status" in result ? result.status : 400, 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 // Keep lock — user stays on the page after save
@@ -312,7 +341,9 @@ export default async function quotationsRoutes(
action: "update", action: "update",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); 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); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await deleteOffer(id); const result = await deleteOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404); 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 // NAS PDF cleanup is owned by deleteOffer (single owner) — the route
if (existing.quotation_number && existing.created_at) { // used to repeat it here, which always failed the second time and logged
const yr = new Date(existing.created_at).getFullYear(); // a spurious error.
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`,
);
}
}
await logAudit({ await logAudit({
request, request,
@@ -351,7 +380,8 @@ export default async function quotationsRoutes(
action: "delete", action: "delete",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); return success(reply, null, 200, "Nabídka smazána");
}, },
@@ -379,15 +409,71 @@ export default async function quotationsRoutes(
year, year,
); );
const file = nasOffersManager.readOfferPdf(relPath); 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 return reply
.type("application/pdf") .type("application/pdf")
.header( .header(
"Content-Disposition", "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);
}
}, },
); );
} }

View File

@@ -7,6 +7,10 @@ import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition"; import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import {
encodeCustomFields,
decodeCustomFields,
} from "../../utils/custom-fields";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { import {
@@ -226,7 +230,7 @@ export default async function warehouseRoutes(
// SUPPLIERS // SUPPLIERS
// ============================================================= // =============================================================
// GET /suppliers — paginated list, search by name/ico/contact_person // GET /suppliers — paginated list, search by name/ico/city
fastify.get( fastify.get(
"/suppliers", "/suppliers",
{ preHandler: requirePermission("warehouse.manage") }, { preHandler: requirePermission("warehouse.manage") },
@@ -240,7 +244,7 @@ export default async function warehouseRoutes(
where.OR = [ where.OR = [
{ name: { contains: search } }, { name: { contains: search } },
{ ico: { contains: search } }, { ico: { contains: search } },
{ contact_person: { contains: search } }, { city: { contains: search } },
]; ];
} }
@@ -254,9 +258,16 @@ export default async function warehouseRoutes(
prisma.sklad_suppliers.count({ where }), 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( return paginated(
reply, reply,
suppliers, enriched,
buildPaginationMeta(total, page, limit), buildPaginationMeta(total, page, limit),
); );
}, },
@@ -275,7 +286,10 @@ export default async function warehouseRoutes(
}); });
if (!supplier) return error(reply, "Dodavatel nenalezen", 404); if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
return success(reply, supplier); return success(reply, {
...supplier,
custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields,
});
}, },
); );
@@ -293,11 +307,12 @@ export default async function warehouseRoutes(
name: body.name, name: body.name,
ico: body.ico ?? null, ico: body.ico ?? null,
dic: body.dic ?? null, dic: body.dic ?? null,
contact_person: body.contact_person ?? null, street: body.street ?? null,
email: body.email ?? null, city: body.city ?? null,
phone: body.phone ?? null, postal_code: body.postal_code ?? null,
address: body.address ?? null, country: body.country ?? null,
notes: body.notes ?? null, // Suppliers have no PDF field-order picker — order is always [].
custom_fields: encodeCustomFields(body.custom_fields, []),
is_active: body.is_active ?? true, is_active: body.is_active ?? true,
}, },
}); });
@@ -312,7 +327,7 @@ export default async function warehouseRoutes(
newValues: { newValues: {
name: supplier.name, name: supplier.name,
ico: supplier.ico, ico: supplier.ico,
contact_person: supplier.contact_person, city: supplier.city,
is_active: supplier.is_active, is_active: supplier.is_active,
}, },
}); });
@@ -342,12 +357,13 @@ export default async function warehouseRoutes(
if (body.name !== undefined) updateData.name = body.name; if (body.name !== undefined) updateData.name = body.name;
if (body.ico !== undefined) updateData.ico = body.ico; if (body.ico !== undefined) updateData.ico = body.ico;
if (body.dic !== undefined) updateData.dic = body.dic; if (body.dic !== undefined) updateData.dic = body.dic;
if (body.contact_person !== undefined) if (body.street !== undefined) updateData.street = body.street;
updateData.contact_person = body.contact_person; if (body.city !== undefined) updateData.city = body.city;
if (body.email !== undefined) updateData.email = body.email; if (body.postal_code !== undefined)
if (body.phone !== undefined) updateData.phone = body.phone; updateData.postal_code = body.postal_code;
if (body.address !== undefined) updateData.address = body.address; if (body.country !== undefined) updateData.country = body.country;
if (body.notes !== undefined) updateData.notes = body.notes; if (body.custom_fields !== undefined)
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
if (body.is_active !== undefined) updateData.is_active = body.is_active; if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_suppliers.update({ const updated = await prisma.sklad_suppliers.update({
@@ -365,13 +381,13 @@ export default async function warehouseRoutes(
oldValues: { oldValues: {
name: existing.name, name: existing.name,
ico: existing.ico, ico: existing.ico,
contact_person: existing.contact_person, city: existing.city,
is_active: existing.is_active, is_active: existing.is_active,
}, },
newValues: { newValues: {
name: updated.name, name: updated.name,
ico: updated.ico, ico: updated.ico,
contact_person: updated.contact_person, city: updated.city,
is_active: updated.is_active, is_active: updated.is_active,
}, },
}); });

View File

@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
message: "Číslo musí být kladné", 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. */ /** number|string → positive integer id; rejects NaN/≤0/non-integer. */
export const intIdFromForm = z export const intIdFromForm = z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
@@ -154,3 +162,17 @@ export const emailOrEmpty = z.union([
z.literal(""), z.literal(""),
z.string().max(255).email("Neplatný email"), 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(),
});

View File

@@ -6,6 +6,7 @@ import {
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
DocumentSectionSchema,
} from "./common"; } from "./common";
const InvoiceItemSchema = z.object({ const InvoiceItemSchema = z.object({
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
issued_by: z.string().max(255).nullish(), issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(), billing_text: z.string().max(8000).nullish(),
language: z.string().max(20).optional().default("cs"), 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(), internal_notes: z.string().max(8000).nullish(),
items: z.array(InvoiceItemSchema).optional(), 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({ export const UpdateInvoiceSchema = z.object({
@@ -61,10 +67,10 @@ export const UpdateInvoiceSchema = z.object({
issue_date: z.union([z.string().max(255), z.null()]).optional(), issue_date: z.union([z.string().max(255), z.null()]).optional(),
due_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(), tax_date: z.union([z.string().max(255), z.null()]).optional(),
notes: z.string().max(8000).nullish(),
internal_notes: z.string().max(8000).nullish(), internal_notes: z.string().max(8000).nullish(),
paid_date: z.union([z.string().max(255), z.null()]).optional(), paid_date: z.union([z.string().max(255), z.null()]).optional(),
items: z.array(InvoiceItemSchema).optional(), items: z.array(InvoiceItemSchema).optional(),
sections: z.array(DocumentSectionSchema).optional(),
}); });
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>; export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -4,6 +4,7 @@ import {
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
isoDateString, isoDateString,
DocumentSectionSchema,
} from "./common"; } from "./common";
export const IssuedOrderItemSchema = z.object({ export const IssuedOrderItemSchema = z.object({
@@ -32,15 +33,19 @@ export const CreateIssuedOrderSchema = z.object({
order_date: isoDateString.nullish(), order_date: isoDateString.nullish(),
delivery_date: isoDateString.nullish(), delivery_date: isoDateString.nullish(),
language: z.string().max(5).optional(), 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(),
// Editable heading above the PDF items table; empty/null falls back to the // 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). // default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
order_text: z.string().max(500).nullish(), order_text: z.string().max(500).nullish(),
notes: z.string().nullish(),
internal_notes: z.string().nullish(), internal_notes: z.string().nullish(),
items: z.array(IssuedOrderItemSchema).optional(), 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,
});

View File

@@ -1,52 +1,43 @@
import { z } from "zod"; import { z } from "zod";
import { import {
numberFromForm, nonNegativeIntFromForm,
nonNegativeNumberFromForm, nonNegativeNumberFromForm,
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
isoDateString,
DocumentSectionSchema,
} from "./common"; } 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({ const QuotationItemSchema = z.object({
description: z.string().max(8000).nullish(), description: z.string().max(500).nullish(),
item_description: z.string().max(8000).nullish(), item_description: z.string().max(8000).nullish(),
quantity: positiveNumberFromForm.default(1), quantity: positiveNumberFromForm.default(1),
unit: z.string().max(255).nullish(), unit: z.string().max(20).nullish(),
unit_price: numberFromForm.default(0), unit_price: nonNegativeNumberFromForm.default(0),
is_included_in_total: booleanFromForm.optional().default(true), 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({ // Shared with issued orders (scope_sections ≡ issued_order_sections).
title: z.string().max(255).nullish(), const ScopeSectionSchema = DocumentSectionSchema;
title_cz: z.string().max(255).nullish(),
content: z.string().max(8000).nullish(),
position: nonNegativeNumberFromForm.optional(),
});
// 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({ export const CreateQuotationSchema = z.object({
quotation_number: z.string().max(255).nullish(), // DB: quotations.quotation_number VarChar(50).
project_code: z.string().max(255).nullish(), 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(), customer_id: nullableIntIdFromForm.nullish(),
created_at: z.string().max(255).nullish(), created_at: isoDateString.nullish(),
valid_until: z.string().max(255).nullish(), valid_until: isoDateString.nullish(),
currency: z.string().max(20).optional().default("CZK"),
language: z.string().max(20).optional().default("cs"),
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(),
currency: z.string().max(20).optional(), currency: z.string().max(20).optional(),
language: z.string().max(20).optional(), language: z.string().max(20).optional(),
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(), status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
@@ -56,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
sections: z.array(ScopeSectionSchema).optional(), 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 CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>; export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;

View File

@@ -8,7 +8,6 @@ import {
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
isoDateString, isoDateString,
emailOrEmpty,
} from "./common"; } from "./common";
// === Categories === // === Categories ===
@@ -26,26 +25,30 @@ export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>; export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
// === Suppliers === // === Suppliers ===
// Full mirror of the customers model: structured address + custom_fields
// (the dedicated contact_person/email/phone/notes columns and the free-text
// `address` blob were dropped; legacy clients sending them are silently
// stripped by z.object). Limits are DB-aligned.
export const CreateSupplierSchema = z.object({ export const CreateSupplierSchema = z.object({
name: z.string().min(1, "Název je povinný").max(255), name: z.string().min(1, "Název je povinný").max(255),
ico: z.string().max(255).nullish(), ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(), dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(), street: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(), city: z.string().max(255).nullish(),
phone: z.string().max(50).nullish(), postal_code: z.string().max(20).nullish(),
address: z.string().max(5000).nullish(), country: z.string().max(100).nullish(),
notes: z.string().max(5000).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional().default(true), is_active: booleanFromForm.optional().default(true),
}); });
export const UpdateSupplierSchema = z.object({ export const UpdateSupplierSchema = z.object({
name: z.string().min(1, "Název je povinný").max(255).optional(), name: z.string().min(1, "Název je povinný").max(255).optional(),
ico: z.string().max(255).nullish(), ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(), dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(), street: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(), city: z.string().max(255).nullish(),
phone: z.string().max(50).nullish(), postal_code: z.string().max(20).nullish(),
address: z.string().max(5000).nullish(), country: z.string().max(100).nullish(),
notes: z.string().max(5000).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional(), is_active: booleanFromForm.optional(),
}); });
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>; export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;

View File

@@ -38,6 +38,7 @@ import projectFilesRoutes from "./routes/admin/project-files";
import warehouseRoutes from "./routes/admin/warehouse"; import warehouseRoutes from "./routes/admin/warehouse";
import planRoutes from "./routes/admin/plan"; import planRoutes from "./routes/admin/plan";
import aiRoutes from "./routes/admin/ai"; import aiRoutes from "./routes/admin/ai";
import aresRoutes from "./routes/admin/ares";
const app = Fastify({ const app = Fastify({
logger: { logger: {
@@ -162,6 +163,7 @@ async function start() {
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
await app.register(planRoutes, { prefix: "/api/admin/plan" }); await app.register(planRoutes, { prefix: "/api/admin/plan" });
await app.register(aiRoutes, { prefix: "/api/admin/ai" }); await app.register(aiRoutes, { prefix: "/api/admin/ai" });
await app.register(aresRoutes, { prefix: "/api/admin/ares" });
// --- Frontend: Vite dev middleware (dev only) --- // --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) { if (!config.isProduction) {
@@ -204,12 +206,30 @@ async function start() {
root: path.join(__dirname, "..", "dist-client"), root: path.join(__dirname, "..", "dist-client"),
prefix: "/", prefix: "/",
wildcard: false, wildcard: false,
// Deploy-skew caching contract (per the Vite deploy guide):
// - /assets/* names are content-hashed → safe to cache forever
// (a changed file always gets a NEW name, so "immutable" is correct).
// - index.html (and any non-hashed file) must always be revalidated,
// otherwise a cached copy keeps referencing deleted old chunks.
setHeaders: (res, filePath) => {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else {
res.setHeader("Cache-Control", "no-cache");
}
},
}); });
app.setNotFoundHandler((request, reply) => { app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api/")) { if (request.url.startsWith("/api/")) {
return reply.status(404).send({ success: false, error: "Not found" }); return reply.status(404).send({ success: false, error: "Not found" });
} }
// A missing hashed asset (old chunk after a deploy) must 404, NOT get
// the SPA index.html fallback — HTML-as-JS masks the failure and breaks
// the client's vite:preloadError reload recovery.
if (request.url.startsWith("/assets/")) {
return reply.status(404).send();
}
return reply.sendFile("index.html"); return reply.sendFile("index.html");
}); });
} }

View File

@@ -0,0 +1,134 @@
/**
* ARES (Administrativní registr ekonomických subjektů) lookups — the public
* MFČR REST API for Czech company data. No auth required. Used by the
* customer/supplier modals to prefill company data from IČO or name.
*
* Docs: https://ares.gov.cz/swagger-ui (REST v3, ekonomicke-subjekty).
* The browser cannot call ares.gov.cz directly (CORS + our CSP connect-src),
* so these run server-side behind /api/admin/ares.
*/
const ARES_BASE = "https://ares.gov.cz/ekonomicke-subjekty-v-be/rest";
const FETCH_TIMEOUT_MS = 8000;
/** Normalized subject shape consumed by the frontend prefill. */
export interface AresSubject {
ico: string;
dic: string | null;
name: string;
street: string;
city: string;
postal_code: string;
country: string;
}
interface AresSidlo {
nazevStatu?: string;
nazevObce?: string;
nazevCastiObce?: string;
nazevUlice?: string;
cisloDomovni?: number;
cisloOrientacni?: number;
cisloOrientacniPismeno?: string;
psc?: number;
textovaAdresa?: string;
}
interface AresEkonomickySubjekt {
ico?: string;
obchodniJmeno?: string;
dic?: string;
sidlo?: AresSidlo;
}
/** "51101" → "511 01" (Czech postal code display format). */
function formatPsc(psc: number | undefined): string {
if (!psc) return "";
const s = String(psc);
return s.length === 5 ? `${s.slice(0, 3)} ${s.slice(3)}` : s;
}
/**
* Street line from the registered office: "Nádražní 485", Prague-style
* orientation numbers become "Ulice 485/12a". Villages without street names
* fall back to the municipality part name, then the municipality itself
* (matching how ARES itself builds textovaAdresa).
*/
function buildStreet(sidlo: AresSidlo): string {
const streetName =
sidlo.nazevUlice || sidlo.nazevCastiObce || sidlo.nazevObce || "";
let num = sidlo.cisloDomovni != null ? String(sidlo.cisloDomovni) : "";
if (sidlo.cisloOrientacni != null) {
num += `/${sidlo.cisloOrientacni}${sidlo.cisloOrientacniPismeno || ""}`;
}
return [streetName, num].filter(Boolean).join(" ").trim();
}
function mapSubject(s: AresEkonomickySubjekt): AresSubject {
const sidlo = s.sidlo || {};
return {
ico: s.ico || "",
dic: s.dic || null,
name: s.obchodniJmeno || "",
street: buildStreet(sidlo),
city: sidlo.nazevObce || "",
postal_code: formatPsc(sidlo.psc),
country: sidlo.nazevStatu || "Česká republika",
};
}
export type AresError = "invalid_ico" | "not_found" | "ares_unavailable";
/** Lookup a single subject by its 8-digit IČO. */
export async function aresLookupByIco(
ico: string,
): Promise<AresSubject | { error: AresError }> {
const normalized = ico.replace(/\s+/g, "");
if (!/^\d{8}$/.test(normalized)) return { error: "invalid_ico" };
try {
const response = await fetch(
`${ARES_BASE}/ekonomicke-subjekty/${normalized}`,
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
);
if (response.status === 404) return { error: "not_found" };
if (!response.ok) {
console.error(`[ares] lookup ${normalized} failed: ${response.status}`);
return { error: "ares_unavailable" };
}
const data = (await response.json()) as AresEkonomickySubjekt;
if (!data.ico) return { error: "not_found" };
return mapSubject(data);
} catch (err) {
console.error("[ares] lookup failed:", err);
return { error: "ares_unavailable" };
}
}
/** Search subjects by (part of) the business name; returns up to 10 matches. */
export async function aresSearchByName(
name: string,
): Promise<AresSubject[] | { error: AresError }> {
const query = name.trim();
if (!query) return [];
try {
const response = await fetch(`${ARES_BASE}/ekonomicke-subjekty/vyhledat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ obchodniJmeno: query, pocet: 10, start: 0 }),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
console.error(`[ares] search "${query}" failed: ${response.status}`);
return { error: "ares_unavailable" };
}
const data = (await response.json()) as {
ekonomickeSubjekty?: AresEkonomickySubjekt[];
};
return (data.ekonomickeSubjekty ?? []).map(mapSubject);
} catch (err) {
console.error("[ares] search failed:", err);
return { error: "ares_unavailable" };
}
}

View File

@@ -35,6 +35,12 @@ interface InvoiceItemInput {
position?: number; position?: number;
} }
interface InvoiceSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
}
/** /**
* Body shape accepted by createInvoice/updateInvoice. All fields optional and * Body shape accepted by createInvoice/updateInvoice. All fields optional and
* loosely typed because the payload arrives validated-but-coerced from the Zod * loosely typed because the payload arrives validated-but-coerced from the Zod
@@ -62,10 +68,10 @@ export interface InvoiceInput {
issued_by?: string | null; issued_by?: string | null;
billing_text?: string | null; billing_text?: string | null;
language?: string; language?: string;
notes?: string | null;
internal_notes?: string | null; internal_notes?: string | null;
paid_date?: string | null; paid_date?: string | null;
items?: InvoiceItemInput[]; items?: InvoiceItemInput[];
sections?: InvoiceSectionInput[];
[key: string]: unknown; [key: string]: unknown;
} }
@@ -469,14 +475,17 @@ export async function getInvoice(id: number) {
include: { include: {
customers: true, customers: true,
invoice_items: { orderBy: { position: "asc" } }, invoice_items: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
invoice_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
orders: { select: { id: true, order_number: true } }, orders: { select: { id: true, order_number: true } },
}, },
}); });
if (!invoice) return null; if (!invoice) return null;
const { invoice_items, ...rest } = invoice; const { invoice_items, invoice_sections, ...rest } = invoice;
return { return {
...rest, ...rest,
items: invoice_items, items: invoice_items,
sections: invoice_sections,
customer: invoice.customers, customer: invoice.customers,
customer_name: invoice.customers?.name || null, customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null, order_number: invoice.orders?.order_number || null,
@@ -499,7 +508,10 @@ export async function createInvoice(body: InvoiceInput) {
explicit ?? explicit ??
(status === "draft" ? null : (await generateInvoiceNumber()).number); (status === "draft" ? null : (await generateInvoiceNumber()).number);
const invoice = await prisma.invoices.create({ // Header + items + sections are ONE write — a failure mid-way must not
// leave a headerless/partial invoice behind.
const invoice = await prisma.$transaction(async (tx) => {
const created = await tx.invoices.create({
data: { data: {
invoice_number: invoiceNumber, invoice_number: invoiceNumber,
order_id: body.order_id ? Number(body.order_id) : null, order_id: body.order_id ? Number(body.order_id) : null,
@@ -508,7 +520,9 @@ export async function createInvoice(body: InvoiceInput) {
currency: body.currency ? String(body.currency) : "CZK", currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false, apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null, payment_method: body.payment_method
? String(body.payment_method)
: null,
constant_symbol: body.constant_symbol constant_symbol: body.constant_symbol
? String(body.constant_symbol) ? String(body.constant_symbol)
: null, : null,
@@ -522,15 +536,16 @@ export async function createInvoice(body: InvoiceInput) {
issued_by: body.issued_by ? String(body.issued_by) : null, issued_by: body.issued_by ? String(body.issued_by) : null,
billing_text: body.billing_text ? String(body.billing_text) : null, billing_text: body.billing_text ? String(body.billing_text) : null,
language: body.language ? String(body.language) : "cs", language: body.language ? String(body.language) : "cs",
notes: body.notes ? String(body.notes) : null, internal_notes: body.internal_notes
internal_notes: body.internal_notes ? String(body.internal_notes) : null, ? String(body.internal_notes)
: null,
}, },
}); });
if (Array.isArray(body.items)) { if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({ await tx.invoice_items.createMany({
data: body.items.map((item, i) => ({ data: body.items.map((item, i) => ({
invoice_id: invoice.id, invoice_id: created.id,
description: item.description ?? null, description: item.description ?? null,
item_description: item.item_description ?? null, item_description: item.item_description ?? null,
quantity: item.quantity ?? 1, quantity: item.quantity ?? 1,
@@ -542,6 +557,21 @@ export async function createInvoice(body: InvoiceInput) {
}); });
} }
if (Array.isArray(body.sections)) {
await tx.invoice_sections.createMany({
data: body.sections.map((s, i) => ({
invoice_id: created.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: i,
})),
});
}
return created;
});
return invoice; return invoice;
} }
@@ -601,10 +631,8 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null; data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
} }
// Notes editable in draft/issued/overdue // Internal notes editable in draft/issued/overdue (never printed)
if (editable) { if (editable) {
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined) if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes data.internal_notes = body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
@@ -644,9 +672,13 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
await prisma.invoices.update({ where: { id }, data }); await prisma.invoices.update({ where: { id }, data });
} }
// Allow items update while editable (draft/issued/overdue). // Allow items/sections full-replace while editable (draft/issued/overdue)
if (editable && Array.isArray(body.items)) { // both in ONE transaction so a failure can't leave a half-replaced document.
const replaceItems = editable && Array.isArray(body.items);
const replaceSections = editable && Array.isArray(body.sections);
if (replaceItems || replaceSections) {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
if (replaceItems) {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } }); await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({ await tx.invoice_items.createMany({
data: body.items!.map((item, i) => ({ data: body.items!.map((item, i) => ({
@@ -660,6 +692,19 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
position: item.position ?? i, position: item.position ?? i,
})), })),
}); });
}
if (replaceSections) {
await tx.invoice_sections.deleteMany({ where: { invoice_id: id } });
await tx.invoice_sections.createMany({
data: body.sections!.map((s, i) => ({
invoice_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: i,
})),
});
}
}); });
} }

View File

@@ -1,4 +1,4 @@
import type { $Enums } from "@prisma/client"; import { Prisma, type $Enums } from "@prisma/client";
import prisma from "../config/database"; import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date"; import { utcMidnightOfLocalDay } from "../utils/date";
import { import {
@@ -6,7 +6,9 @@ import {
previewIssuedOrderNumber, previewIssuedOrderNumber,
releaseIssuedOrderNumber, releaseIssuedOrderNumber,
assignIssuedOrderNumber, assignIssuedOrderNumber,
isIssuedOrderNumberTaken,
} from "./numbering.service"; } from "./numbering.service";
import { nasOrdersManager } from "./nas-financials-manager";
export interface IssuedOrderItemInput { export interface IssuedOrderItemInput {
description?: string | null; description?: string | null;
@@ -17,6 +19,13 @@ export interface IssuedOrderItemInput {
position?: number | null; position?: number | null;
} }
export interface IssuedOrderSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
position?: number | null;
}
export interface IssuedOrderInput { export interface IssuedOrderInput {
po_number?: string | number | null; po_number?: string | number | null;
supplier_id?: number | string | null; supplier_id?: number | string | null;
@@ -26,13 +35,10 @@ export interface IssuedOrderInput {
order_date?: string | null; order_date?: string | null;
delivery_date?: string | null; delivery_date?: string | null;
language?: string; language?: string;
delivery_terms?: string | null;
payment_terms?: string | null;
issued_by?: string | null;
order_text?: string | null; order_text?: string | null;
notes?: string | null;
internal_notes?: string | null; internal_notes?: string | null;
items?: IssuedOrderItemInput[]; items?: IssuedOrderItemInput[];
sections?: IssuedOrderSectionInput[];
[key: string]: unknown; [key: string]: unknown;
} }
@@ -100,9 +106,29 @@ const ALLOWED_SORT_FIELDS = [
"po_number", "po_number",
"status", "status",
"order_date", "order_date",
"created_at",
"currency", "currency",
]; ];
/**
* Non-status update fields — used by the editable-state guard so a field edit
* on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to
* be silently dropped). `po_number` is stripped by the schema and the service
* never reads it on update.
*/
const NON_STATUS_UPDATE_FIELDS = [
"supplier_id",
"currency",
"exchange_rate",
"order_date",
"delivery_date",
"language",
"order_text",
"internal_notes",
"items",
"sections",
] as const;
/** /**
* NET-only total: issued orders (PO) are not tax documents — VAT never appears * NET-only total: issued orders (PO) are not tax documents — VAT never appears
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp. * on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
@@ -205,19 +231,25 @@ export async function getIssuedOrder(id: number) {
name: true, name: true,
ico: true, ico: true,
dic: true, dic: true,
address: true, street: true,
email: true, city: true,
phone: true, postal_code: true,
country: true,
}, },
}, },
issued_order_items: { orderBy: { position: "asc" } }, issued_order_items: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
issued_order_sections: {
orderBy: [{ position: "asc" }, { id: "asc" }],
},
}, },
}); });
if (!order) return null; if (!order) return null;
const { issued_order_items, ...rest } = order; const { issued_order_items, issued_order_sections, ...rest } = order;
return { return {
...rest, ...rest,
items: issued_order_items, items: issued_order_items,
sections: issued_order_sections,
supplier: order.suppliers, supplier: order.suppliers,
supplier_name: order.suppliers?.name || null, supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [], valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
@@ -225,7 +257,8 @@ export async function getIssuedOrder(id: number) {
} }
export async function createIssuedOrder(body: IssuedOrderInput) { export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => { try {
return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft"; const status = body.status ? String(body.status) : "draft";
// Validate the referenced supplier exists BEFORE the insert — a dangling // Validate the referenced supplier exists BEFORE the insert — a dangling
@@ -249,6 +282,15 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
body.po_number !== "" body.po_number !== ""
? String(body.po_number) ? String(body.po_number)
: null; : null;
// Re-check caller-supplied uniqueness INSIDE the transaction so the
// check-then-insert window can't race a concurrent create (mirrors
// createOffer). generateIssuedOrderNumber verifies its own numbers,
// so this guards only the explicit path.
if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) {
return { error: "po_number_taken" as const };
}
const poNumber = const poNumber =
explicit ?? explicit ??
(status === "draft" (status === "draft"
@@ -273,13 +315,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
? new Date(String(body.delivery_date)) ? new Date(String(body.delivery_date))
: null, : null,
language: body.language ? String(body.language) : "cs", language: body.language ? String(body.language) : "cs",
delivery_terms: body.delivery_terms
? String(body.delivery_terms)
: null,
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
order_text: body.order_text ? String(body.order_text) : null, order_text: body.order_text ? String(body.order_text) : null,
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes internal_notes: body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
: null, : null,
@@ -300,8 +336,32 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
}); });
} }
if (Array.isArray(body.sections)) {
await tx.issued_order_sections.createMany({
data: body.sections.map((s, i) => ({
issued_order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return order; return order;
}); });
} catch (err) {
// P2002 on the po_number unique index = a concurrent insert won the race
// between the in-tx check and our insert — surface the same clean
// conflict token instead of a 500 (mirrors offers).
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return { error: "po_number_taken" as const };
}
throw err;
}
} }
export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
@@ -318,18 +378,22 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
} }
} }
// Editable-state guard: non-status fields may change only in draft/sent.
// In confirmed/completed/cancelled a field edit is REJECTED explicitly
// (offers parity — it used to be silently ignored). Status-only transition
// payloads (e.g. confirmed -> completed) still work.
const editable = currentStatus === "draft" || currentStatus === "sent"; const editable = currentStatus === "draft" || currentStatus === "sent";
if (
!editable &&
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
) {
return { error: "not_editable" as const, currentStatus };
}
const data: Record<string, unknown> = { modified_at: new Date() }; const data: Record<string, unknown> = { modified_at: new Date() };
if (editable) { if (editable) {
const strFields = [ const strFields = ["currency", "language", "order_text"];
"currency",
"language",
"delivery_terms",
"payment_terms",
"issued_by",
"order_text",
];
for (const f of strFields) { for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
} }
@@ -356,8 +420,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
data.delivery_date = body.delivery_date data.delivery_date = body.delivery_date
? new Date(String(body.delivery_date)) ? new Date(String(body.delivery_date))
: null; : null;
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined) if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes data.internal_notes = body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
@@ -375,17 +437,19 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
body.status !== undefined && body.status !== undefined &&
String(body.status) === "sent"; String(body.status) === "sent";
if (finalizing) { // ONE transaction for the header write, the finalize numbering AND the
// items/sections full-replace. The items replace used to run in a SECOND
// transaction after the header tx — a failure between the two left a torn
// write (new header, old items). Now either everything lands or nothing.
const replaceItems = editable && Array.isArray(body.items);
const replaceSections = editable && Array.isArray(body.sections);
let assignedNumber: string | null = null;
if (finalizing || replaceItems || replaceSections) {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.issued_orders.update({ where: { id }, data }); await tx.issued_orders.update({ where: { id }, data });
await assignIssuedOrderNumber(id, tx); if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx);
}); if (replaceItems) {
} else {
await prisma.issued_orders.update({ where: { id }, data });
}
if (editable && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.issued_order_items.deleteMany({ await tx.issued_order_items.deleteMany({
where: { issued_order_id: id }, where: { issued_order_id: id },
}); });
@@ -400,21 +464,40 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
position: item.position ?? i, position: item.position ?? i,
})), })),
}); });
}
if (replaceSections) {
await tx.issued_order_sections.deleteMany({
where: { issued_order_id: id },
});
await tx.issued_order_sections.createMany({
data: body.sections!.map((s, i) => ({
issued_order_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
}); });
} }
});
} else {
await prisma.issued_orders.update({ where: { id }, data });
}
// newValues for the audit diff: the header fields actually written plus the
// replaced collections (JSON.stringify drops the undefined = untouched keys).
const newValues: Record<string, unknown> = { ...data };
if (replaceItems) newValues.items = body.items;
if (replaceSections) newValues.sections = body.sections;
// Reflect the number the finalize transition just assigned (existing was read // Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its po_number is still the pre-finalize null). // before the assign, so its po_number is still the pre-finalize null).
const poNumber = finalizing return {
? (( id,
await prisma.issued_orders.findUnique({ po_number: assignedNumber ?? existing.po_number,
where: { id }, oldValues: existing as unknown as Record<string, unknown>,
select: { po_number: true }, newValues,
}) };
)?.po_number ?? existing.po_number)
: existing.po_number;
return { id, po_number: poNumber };
} }
export async function deleteIssuedOrder(id: number) { export async function deleteIssuedOrder(id: number) {
@@ -428,6 +511,23 @@ export async function deleteIssuedOrder(id: number) {
? new Date(existing.created_at).getFullYear() ? new Date(existing.created_at).getFullYear()
: new Date().getFullYear(); : new Date().getFullYear();
await releaseIssuedOrderNumber(year, existing.po_number ?? undefined); await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);
// Remove the archived PDF from the NAS so the DB delete doesn't orphan it
// (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so
// an order whose order_date moved after archiving is still found.
// Best-effort: log, never throw.
if (existing.po_number && nasOrdersManager.isConfigured()) {
try {
nasOrdersManager.cleanIssued(existing.po_number);
} catch (e) {
console.error(
"[issued-orders.service] NAS issued-order PDF delete failed for",
existing.po_number,
e,
);
}
}
return existing; return existing;
} }

View File

@@ -52,10 +52,20 @@ export class NasDocumentManager {
// ── Created (issued) documents ─────────────────────────────────── // ── Created (issued) documents ───────────────────────────────────
/** Remove any existing PDF for this document number across all year/month folders */ /**
* Remove any existing PDF for this document number across all year/month
* folders — including stale `<number>_N.pdf` duplicates that the old
* uniquePath-based save left behind when two archive calls raced (the
* save-after-edit fire-and-forget vs. the /file fallback). Document
* numbers are digit-only and never contain "_", so the suffix match
* cannot hit a different document.
*/
cleanIssued(documentNumber: string): void { cleanIssued(documentNumber: string): void {
if (!this.basePath) return; if (!this.basePath) return;
const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const safeBase = this.sanitizeFilename(documentNumber);
const stalePattern = new RegExp(
`^${safeBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(_\\d+)?\\.pdf$`,
);
const issuedDir = path.join(this.basePath, DIR_ISSUED); const issuedDir = path.join(this.basePath, DIR_ISSUED);
try { try {
if (!fs.existsSync(issuedDir)) return; if (!fs.existsSync(issuedDir)) return;
@@ -74,9 +84,10 @@ export class NasDocumentManager {
); );
continue; continue;
} }
const filePath = path.join(monthPath, safeName); for (const file of fs.readdirSync(monthPath)) {
if (fs.existsSync(filePath)) { if (stalePattern.test(file)) {
fs.unlinkSync(filePath); fs.unlinkSync(path.join(monthPath, file));
}
} }
} }
} }
@@ -103,12 +114,17 @@ export class NasDocumentManager {
const dir = this.ensureDir(DIR_ISSUED, year, month); const dir = this.ensureDir(DIR_ISSUED, year, month);
if (!dir) return null; if (!dir) return null;
// Deterministic path, OVERWRITE in place (same model as the offers
// manager): the archive of a numbered document has exactly one canonical
// file. uniquePath here used to spawn `<number>_1.pdf` duplicates
// whenever two archive calls raced — those are user-visible junk on the
// NAS and invisible to the reader/cleaner, which match the exact name.
const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
const fullPath = this.uniquePath(dir, safeName); const fullPath = path.join(dir, safeName);
try { try {
fs.writeFileSync(fullPath, pdfBuffer); fs.writeFileSync(fullPath, pdfBuffer);
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`; return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
} catch (err) { } catch (err) {
console.error( console.error(
"[nas-financials-manager] write failed for issued document:", "[nas-financials-manager] write failed for issued document:",
@@ -119,6 +135,54 @@ export class NasDocumentManager {
} }
} }
/**
* Locate and read the archived PDF for an issued document by its number
* alone, sweeping every Vydané/YYYY/MM folder (the same walk cleanIssued
* uses). Needed because the archive folder is derived from the document
* date at save time and the date can be edited afterwards — a fixed
* expected-path read would miss a file that does exist in another month
* folder. Returns null when not found (callers fall back to a live render).
*/
readIssuedByNumber(
documentNumber: string,
): { data: Buffer; fileName: string } | null {
if (!this.basePath) return null;
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
const issuedDir = path.join(this.basePath, DIR_ISSUED);
try {
if (!fs.existsSync(issuedDir)) return null;
for (const yearDir of fs.readdirSync(issuedDir)) {
const yearPath = path.join(issuedDir, yearDir);
if (!fs.statSync(yearPath).isDirectory()) continue;
for (const monthDir of fs.readdirSync(yearPath)) {
const monthPath = path.join(yearPath, monthDir);
try {
if (!fs.statSync(monthPath).isDirectory()) continue;
} catch (err) {
console.error(
"[nas-financials-manager] stat failed for monthPath:",
monthPath,
err,
);
continue;
}
const filePath = path.join(monthPath, safeName);
if (fs.existsSync(filePath)) {
return { data: fs.readFileSync(filePath), fileName: safeName };
}
}
}
return null;
} catch (err) {
console.error(
"[nas-financials-manager] readIssuedByNumber failed for:",
documentNumber,
err,
);
return null;
}
}
readIssued(relativePath: string): { data: Buffer; fileName: string } | null { readIssued(relativePath: string): { data: Buffer; fileName: string } | null {
const fullPath = this.resolveSafePath(relativePath); const fullPath = this.resolveSafePath(relativePath);
if (!fullPath) return null; if (!fullPath) return null;

View File

@@ -228,18 +228,30 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
return !!existing; return !!existing;
} }
/** Verify an issued-order (PO) number is not already used. */ /** Verify an issued-order (PO) number is not already used. Pass the tx client
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> { * when the check must be part of a transaction (otherwise it runs on a
const existing = await prisma.issued_orders.findFirst({ * separate connection and is only a best-effort pre-check). */
export async function isIssuedOrderNumberTaken(
number: string,
client: Prisma.TransactionClient | PrismaClient = prisma,
): Promise<boolean> {
const existing = await client.issued_orders.findFirst({
where: { po_number: number }, where: { po_number: number },
select: { id: true },
}); });
return !!existing; return !!existing;
} }
/** Verify an offer/quotation number is not already used. */ /** Verify an offer/quotation number is not already used. Pass the tx client
export async function isOfferNumberTaken(number: string): Promise<boolean> { * when the check must be part of a transaction (otherwise it runs on a
const existing = await prisma.quotations.findFirst({ * separate connection and is only a best-effort pre-check). */
export async function isOfferNumberTaken(
number: string,
client: Prisma.TransactionClient | PrismaClient = prisma,
): Promise<boolean> {
const existing = await client.quotations.findFirst({
where: { quotation_number: number }, where: { quotation_number: number },
select: { id: true },
}); });
return !!existing; return !!existing;
} }
@@ -440,7 +452,18 @@ export async function previewInvoiceNumber(
const code = settings?.invoice_type_code || "81"; const code = settings?.invoice_type_code || "81";
const year = _year || new Date().getFullYear(); const year = _year || new Date().getFullYear();
const seq = await previewNextSequence("invoice", year); // Mirror generateInvoiceNumber's collision check (without consuming the
// sequence). The number_sequences counter can lag behind real data, so the
// raw next sequence may already be taken — advance past any number already
// used by an invoice so the preview matches what generateInvoiceNumber
// would actually assign instead of showing a stale, already-used number.
let seq = await previewNextSequence("invoice", year);
for (let attempt = 0; attempt < 1000; attempt++) {
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isInvoiceNumberTaken(number)))
return { number, next_number: number };
seq++;
}
const number = applyPattern(pattern, { year, prefix: "", code, seq }); const number = applyPattern(pattern, { year, prefix: "", code, seq });
return { number, next_number: number }; return { number, next_number: number };
} }
@@ -482,7 +505,18 @@ export async function previewIssuedOrderNumber(
const code = settings?.issued_order_type_code || "72"; const code = settings?.issued_order_type_code || "72";
const year = _year || new Date().getFullYear(); const year = _year || new Date().getFullYear();
const seq = await previewNextSequence("issued_order", year); // Mirror generateIssuedOrderNumber's collision check (without consuming the
// sequence). The number_sequences counter can lag behind real data, so the
// raw next sequence may already be taken — advance past any number already
// used by an issued order so the preview matches what
// generateIssuedOrderNumber would actually assign.
let seq = await previewNextSequence("issued_order", year);
for (let attempt = 0; attempt < 1000; attempt++) {
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isIssuedOrderNumberTaken(number)))
return { number, next_number: number };
seq++;
}
const number = applyPattern(pattern, { year, prefix: "", code, seq }); const number = applyPattern(pattern, { year, prefix: "", code, seq });
return { number, next_number: number }; return { number, next_number: number };
} }

View File

@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import prisma from "../config/database"; import prisma from "../config/database";
import { import {
generateOfferNumber, generateOfferNumber,
previewOfferNumber,
releaseOfferNumber, releaseOfferNumber,
isOfferNumberTaken, isOfferNumberTaken,
assignOfferNumber, assignOfferNumber,
@@ -27,6 +27,44 @@ interface ScopeSectionInput {
// Re-export for convenience // Re-export for convenience
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service"; export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
/**
* Offer status lifecycle (mirrors issued orders' table): a draft must be
* finalized to `active` first (that's when the number is assigned), an active
* offer can be ordered or invalidated, an ordered one only invalidated, and
* `invalidated` is terminal. The create-order flow (orders.service
* createOrderFromQuotation) consults this table too — an offer can only become
* `ordered` from a status that allows the transition.
*/
export const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["active"],
active: ["ordered", "invalidated"],
ordered: ["invalidated"],
invalidated: [],
};
/** Fields (non-status) are editable only while the offer is draft/active. */
function isEditableStatus(status: string): boolean {
return status === "draft" || status === "active";
}
/**
* Non-status update fields — used by the editable-state guard so a field edit
* on an ordered/invalidated offer is rejected EXPLICITLY (not silently
* dropped). `quotation_number` is stripped by the schema and is not listed.
*/
const NON_STATUS_UPDATE_FIELDS = [
"project_code",
"customer_id",
"created_at",
"valid_until",
"currency",
"language",
"scope_title",
"scope_description",
"items",
"sections",
] as const;
const ALLOWED_SORT_FIELDS = [ const ALLOWED_SORT_FIELDS = [
"id", "id",
"quotation_number", "quotation_number",
@@ -102,16 +140,24 @@ export async function listOffers(params: ListOffersParams) {
const where = buildOfferWhere(params); const where = buildOfferWhere(params);
// Tiebreak on id: created_at is second-precision, so same-second rows would
// otherwise paginate non-deterministically (project ordering rule).
const orderBy: Record<string, "asc" | "desc">[] = [
{ [sortField]: order },
{ id: order },
];
const [quotations, total] = await Promise.all([ const [quotations, total] = await Promise.all([
prisma.quotations.findMany({ prisma.quotations.findMany({
where, where,
skip, skip,
take: limit, take: limit,
orderBy: { [sortField]: order }, orderBy,
include: { include: {
customers: { select: { id: true, name: true } }, customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } }, quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
}, },
}), }),
prisma.quotations.count({ where }), prisma.quotations.count({ where }),
@@ -152,12 +198,12 @@ export async function getOfferTotals(
): Promise<{ totals: CurrencyAmount[] }> { ): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOfferWhere(params); const where = buildOfferWhere(params);
// Only quotation_items — the per-currency NET math needs nothing else
// (customers/scope_sections would just inflate the full-set scan).
const quotations = await prisma.quotations.findMany({ const quotations = await prisma.quotations.findMany({
where, where,
include: { include: {
customers: { select: { id: true, name: true } }, quotation_items: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
}, },
}); });
@@ -184,7 +230,8 @@ export async function getOffer(id: number) {
include: { include: {
customers: true, customers: true,
quotation_items: { orderBy: { position: "asc" } }, quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
}, },
}); });
if (!quotation) return null; if (!quotation) return null;
@@ -207,6 +254,9 @@ export async function getOffer(id: number) {
customer: quotation.customers, customer: quotation.customers,
customer_name: quotation.customers?.name || null, customer_name: quotation.customers?.name || null,
order: orderInfo, order: orderInfo,
// Computed server-side so the frontend renders only legal status buttons
// (same contract as getIssuedOrder).
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
}; };
} }
@@ -219,6 +269,18 @@ export async function createOffer(body: Record<string, any>) {
? String(body.quotation_number) ? String(body.quotation_number)
: null; : null;
// Validate the referenced customer exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400
// (mirrors createIssuedOrder's supplier check).
const customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await tx.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
// Deferred numbering: a draft carries NO quotation_number (the column is // Deferred numbering: a draft carries NO quotation_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed // nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignOfferNumber on draft->active). // only when the draft is finalized (assignOfferNumber on draft->active).
@@ -232,19 +294,15 @@ export async function createOffer(body: Record<string, any>) {
// generateOfferNumber already verifies its own generated numbers, so this // generateOfferNumber already verifies its own generated numbers, so this
// guards the caller-supplied path. // guards the caller-supplied path.
if (explicitNumber !== null) { if (explicitNumber !== null) {
const taken = await isOfferNumberTaken(explicitNumber); const taken = await isOfferNumberTaken(explicitNumber, tx);
if (taken) { if (taken) return { error: "number_taken" as const };
throw Object.assign(new Error("Číslo nabídky je již použito"), {
status: 400,
});
}
} }
const quotation = await tx.quotations.create({ const quotation = await tx.quotations.create({
data: { data: {
quotation_number: quotationNumber, quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null, project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null, customer_id: customerId,
created_at: body.created_at created_at: body.created_at
? new Date(String(body.created_at)) ? new Date(String(body.created_at))
: undefined, : undefined,
@@ -291,11 +349,14 @@ export async function createOffer(body: Record<string, any>) {
return quotation; return quotation;
}); });
} catch (err) { } catch (err) {
if (err instanceof Error && "status" in err) { // P2002 on the quotation_number unique index = a concurrent insert won the
return { // race between the in-tx check and our insert — surface the same clean
error: err.message, // conflict token instead of a 500 (mirrors createIssuedOrder).
status: (err as Error & { status: number }).status, if (
} as const; err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return { error: "number_taken" as const };
} }
throw err; throw err;
} }
@@ -304,31 +365,53 @@ export async function createOffer(body: Record<string, any>) {
export async function updateOffer(id: number, body: Record<string, any>) { export async function updateOffer(id: number, body: Record<string, any>) {
const existing = await prisma.quotations.findUnique({ where: { id } }); const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const }; if (!existing) return { error: "not_found" as const };
if (existing.status === "invalidated")
return { error: "invalidated" as const };
// A finalized offer's number is immutable. A draft has no number yet const currentStatus = existing.status;
// (quotation_number === null) — it is assigned on finalize, not editable here,
// so an empty/absent submitted value on a draft is not a "change" and must not // Status changes must follow the transition table (issued-orders parity).
// block the draft->active finalize. if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
// Editable-state guard: non-status fields may change only in draft/active.
// In ordered/invalidated a field edit is REJECTED explicitly (it used to be
// possible to rewrite everything in any status except invalidated).
// Status-only transition payloads (e.g. ordered -> invalidated) still work.
const editable = isEditableStatus(currentStatus);
if ( if (
existing.quotation_number !== null && !editable &&
body.quotation_number !== undefined && NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
String(body.quotation_number) !== existing.quotation_number
) { ) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const; return { error: "not_editable" as const, currentStatus };
}
// Explicit null CLEARS the customer; a set id must exist (dangling FK would
// surface as a P2003 500). The old `Number(null)` coerced null to 0.
let customerId: number | null | undefined = undefined;
if (body.customer_id !== undefined) {
customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await prisma.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
} }
// Finalize transition draft -> active: assign the official number ATOMICALLY // Finalize transition draft -> active: assign the official number ATOMICALLY
// with the status change. assignOfferNumber is idempotent. // with the status change. assignOfferNumber is idempotent.
const finalizing = const finalizing =
existing.status === "draft" && currentStatus === "draft" &&
body.status !== undefined && body.status !== undefined &&
String(body.status) === "active"; String(body.status) === "active";
const data = { const data = {
customer_id: customer_id: customerId,
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
created_at: created_at:
body.created_at !== undefined body.created_at !== undefined
? body.created_at ? body.created_at
@@ -402,19 +485,39 @@ export async function updateOffer(id: number, body: Record<string, any>) {
await prisma.quotations.update({ where: { id }, data }); await prisma.quotations.update({ where: { id }, data });
} }
// newValues for the audit diff: the header fields actually written plus the
// replaced collections (JSON.stringify drops the undefined = untouched keys).
const newValues: Record<string, unknown> = { ...data };
if (Array.isArray(body.items)) newValues.items = body.items;
if (Array.isArray(body.sections)) newValues.sections = body.sections;
// Reflect the number the finalize transition just assigned (existing was read // Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its quotation_number is still the pre-finalize null). // before the assign, so its quotation_number is still the pre-finalize null).
return { return {
id, id,
quotation_number: assignedNumber ?? existing.quotation_number, quotation_number: assignedNumber ?? existing.quotation_number,
oldValues: existing as unknown as Record<string, unknown>,
newValues,
}; };
} }
export async function deleteOffer(id: number) { export async function deleteOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } }); const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null; if (!existing) return { error: "not_found" as const };
try {
await prisma.quotations.delete({ where: { id } }); await prisma.quotations.delete({ where: { id } });
} catch (err) {
// P2003 = the offer is referenced by an order/project via a Restrict FK —
// surface a clean conflict token instead of a 500.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2003"
) {
return { error: "linked" as const };
}
throw err;
}
const year = existing.created_at const year = existing.created_at
? new Date(existing.created_at).getFullYear() ? new Date(existing.created_at).getFullYear()
@@ -422,6 +525,8 @@ export async function deleteOffer(id: number) {
await releaseOfferNumber(year, existing.quotation_number ?? undefined); await releaseOfferNumber(year, existing.quotation_number ?? undefined);
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it. // Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
// The service is the SINGLE owner of this cleanup (the route used to repeat
// it and always logged a spurious second-delete failure).
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the // Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
// file is already gone, so a NAS outage / missing file never throws here. // file is already gone, so a NAS outage / missing file never throws here.
if (existing.quotation_number && nasOffersManager.isConfigured()) { if (existing.quotation_number && nasOffersManager.isConfigured()) {
@@ -440,7 +545,7 @@ export async function deleteOffer(id: number) {
} }
} }
return existing; return { data: existing };
} }
export async function duplicateOffer(id: number) { export async function duplicateOffer(id: number) {
@@ -448,7 +553,8 @@ export async function duplicateOffer(id: number) {
where: { id }, where: { id },
include: { include: {
quotation_items: { orderBy: { position: "asc" } }, quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
}, },
}); });
if (!original) return null; if (!original) return null;
@@ -503,11 +609,23 @@ export async function duplicateOffer(id: number) {
export async function invalidateOffer(id: number) { export async function invalidateOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } }); const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null; if (!existing) return { error: "not_found" as const };
// The dedicated endpoint must respect the same transition table as PUT:
// only active/ordered offers can be invalidated (a draft is deleted instead,
// invalidated is terminal).
const allowed = VALID_TRANSITIONS[existing.status] || [];
if (!allowed.includes("invalidated")) {
return {
error: "invalid_transition" as const,
currentStatus: existing.status,
newStatus: "invalidated",
};
}
await prisma.quotations.update({ await prisma.quotations.update({
where: { id }, where: { id },
data: { status: "invalidated", modified_at: new Date() }, data: { status: "invalidated", modified_at: new Date() },
}); });
return existing; return { data: existing };
} }

View File

@@ -9,6 +9,7 @@ import {
releaseProjectNumber, releaseProjectNumber,
} from "./numbering.service"; } from "./numbering.service";
import { NasFileManager } from "./nas-file-manager"; import { NasFileManager } from "./nas-file-manager";
import { VALID_TRANSITIONS as OFFER_VALID_TRANSITIONS } from "./offers.service";
// Best-effort NAS folder creation for order-spawned projects, mirroring // Best-effort NAS folder creation for order-spawned projects, mirroring
// projects.service. Stateless singleton; reads NAS_PATH at construction. // projects.service. Stateless singleton; reads NAS_PATH at construction.
@@ -301,7 +302,7 @@ export async function createOrderFromQuotation(
where: { id: quotationId }, where: { id: quotationId },
include: { include: {
quotation_items: { orderBy: { position: "asc" } }, quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } }, scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
}, },
}); });
@@ -312,6 +313,19 @@ export async function createOrderFromQuotation(
status: 400, status: 400,
} as const; } as const;
// The flow sets the offer's status to `ordered`, so it must respect the
// offers transition table: only an offer whose current status allows the
// `ordered` transition qualifies. A draft must be finalized first (it has no
// number yet — an ordered offer without a number must never exist) and an
// invalidated offer is terminal.
const allowedFromStatus = OFFER_VALID_TRANSITIONS[quotation.status] || [];
if (!allowedFromStatus.includes("ordered")) {
return {
error: `Objednávku nelze vytvořit z nabídky ve stavu "${quotation.status}"`,
status: 400,
} as const;
}
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const orderNumber = await generateSharedNumber(tx); const orderNumber = await generateSharedNumber(tx);
// The order keeps the shared (order) sequence; the auto-created project // The order keeps the shared (order) sequence; the auto-created project
@@ -747,7 +761,16 @@ export async function deleteOrder(id: number, deleteFiles = false) {
} }
} }
// Clear quotation back-reference (matching PHP) // Clear the quotation back-reference AND revert its status: an
// `ordered` offer with no order is a dead end (the transition table
// only allows ordered -> invalidated), so it goes back to `active`,
// making it orderable again.
await tx.quotations.updateMany({
where: { order_id: id, status: "ordered" },
data: { order_id: null, status: "active", modified_at: new Date() },
});
// Backstop for rows in any other status (shouldn't exist — only an
// ordered offer can hold an order_id).
await tx.quotations.updateMany({ await tx.quotations.updateMany({
where: { order_id: id }, where: { order_id: id },
data: { order_id: null }, data: { order_id: null },

View File

@@ -0,0 +1,48 @@
/**
* Shared encode/decode for the custom_fields JSON blob carried by customers
* AND warehouse suppliers (one column, PHP-compatible format:
* `{ "fields": [{name, value, showLabel}], "field_order": [...] }`).
* Lifted from routes/admin/customers.ts when suppliers gained the same
* "Vlastní pole" model (2026-06).
*/
/** Encode custom_fields + field_order into a single JSON blob (matching PHP format). */
export 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 the custom_fields JSON blob into separate fields + field_order for the frontend. */
export function decodeCustomFields(raw: string | null): {
custom_fields: unknown[];
field_order: string[];
} {
if (!raw) return { custom_fields: [], 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 || [],
field_order: parsed.field_order || [],
};
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, field_order: [] };
}
return { custom_fields: [], field_order: [] };
} catch {
return { custom_fields: [], field_order: [] };
}
}

View File

@@ -63,7 +63,32 @@ async function getBrowser(): Promise<Browser> {
} }
} }
export async function htmlToPdf(html: string): Promise<Buffer> { export interface HtmlToPdfOptions {
/**
* Chromium footerTemplate HTML rendered in the bottom margin of EVERY page
* — the only true repeating footer Puppeteer supports (CSS @page margin
* boxes are not implemented in Chromium). Styles must be inline and the
* font-size explicit (Chromium defaults it to 0); `.pageNumber` /
* `.totalPages` spans are substituted by Chromium. When set, the bottom
* margin grows to make room and an empty headerTemplate suppresses
* Chromium's default date/title header.
*/
footerTemplate?: string;
/**
* Chromium headerTemplate HTML rendered in the top margin of EVERY page
* (same rules as footerTemplate: inline styles, explicit font-size; images
* must be data: URLs). When set, the top margin grows to 32mm to make room
* — the document body must NOT render its own header then, or page 1 shows
* it twice. NOTE: Chromium lays pages out by the document's CSS @page
* margins when present (they override this option) — keep them in sync.
*/
headerTemplate?: string;
}
export async function htmlToPdf(
html: string,
options: HtmlToPdfOptions = {},
): Promise<Buffer> {
const b = await getBrowser(); const b = await getBrowser();
const page = await b.newPage(); const page = await b.newPage();
// Per-request timeouts so one stuck render (e.g. image tag pointing at a // Per-request timeouts so one stuck render (e.g. image tag pointing at a
@@ -78,7 +103,19 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
const pdf = await page.pdf({ const pdf = await page.pdf({
format: "A4", format: "A4",
printBackground: true, printBackground: true,
margin: { top: "10mm", bottom: "10mm", left: "10mm", right: "10mm" }, margin: {
top: options.headerTemplate ? "32mm" : "10mm",
bottom: options.footerTemplate ? "18mm" : "10mm",
left: "10mm",
right: "10mm",
},
...(options.footerTemplate || options.headerTemplate
? {
displayHeaderFooter: true,
headerTemplate: options.headerTemplate || "<span></span>",
footerTemplate: options.footerTemplate || "<span></span>",
}
: {}),
timeout: 15_000, timeout: 15_000,
}); });
return Buffer.from(pdf); return Buffer.from(pdf);

157
src/utils/pdf-shared.ts Normal file
View File

@@ -0,0 +1,157 @@
import { localDateCzStr } from "./date";
/**
* Shared helpers for the four PDF routes (offers-pdf, issued-orders-pdf,
* invoices-pdf, orders-pdf). These used to be near-verbatim copies in each
* route file; the canonical variants live here:
*
* - `cleanQuillHtml` is the STRICT variant (strips javascript:/data:/vbscript:
* in BOTH href and src — defense-in-depth, DOMPurify runs first).
* - `formatNum` uses the NBSP (U+00A0) thousands separator so amounts never
* line-wrap inside the number.
* - `formatCurrency` is the per-currency-symbol variant from offers
* (€ / $ / Kč / £, anything else falls back to "<num> <code>").
*/
/** dd.MM.yyyy in Europe/Prague; invalid input echoes back as-is. */
export 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 NBSP thousands separator. */
export 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;
}
/** Per-currency symbol formatting (offers variant). */
export function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case "EUR":
return `${formatNum(n, 2)}`;
case "USD":
return `$${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
case "CZK":
return `${formatNum(n, 2)}`;
case "GBP":
return `£${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
default:
return `${formatNum(n, 2)} ${currency}`;
}
}
export function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Company logo from company_settings.logo_data as a data: URI (mime sniffed
* from magic bytes). Data URIs are the ONLY image source Chromium loads in
* header/footer templates; the body templates reuse the same URI.
*/
export function logoDataUriFromSettings(
settings: Record<string, unknown> | null,
): string {
if (!settings?.logo_data) return "";
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";
return `data:${escapeHtml(mime)};base64,${buf.toString("base64")}`;
}
/**
* Unified repeating per-page header for the red-accent PDF family (invoices +
* issued orders), rendered by Puppeteer in the top page margin: company logo
* left (max 22mm tall — same size as the documents' original body header),
* red bold heading right, 2pt #de3a3a rule underneath.
*
* Contract shared with the footers: inline styles only, explicit font-size
* (Chromium defaults template text to 0), images as data: URIs only, padding
* matched to the documents' 12mm @page side margins. The @page top margin
* must be 32mm to make room (htmlToPdf grows it when headerTemplate is set,
* but Chromium lays out by the CSS @page margins — keep them in sync).
*/
export function buildPdfHeaderTemplate(
logoDataUri: string,
heading: string,
): string {
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
</div>
</div>`;
}
/**
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
* (regex pass = defense-in-depth, not the primary sanitizer).
*/
export function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
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
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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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;
}
/** CSS rules for Quill's .ql-indent-1..9 classes (identical in all templates). */
export function buildQuillIndentCss(): string {
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`;
}
return indentCSS;
}