Compare commits

...

15 Commits

Author SHA1 Message Date
BOHA
f87e110359 chore(release): v2.4.9 - cache-header hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:37 +02:00
BOHA
f1b1329c1b fix(spa): cacheControl:false so custom cache headers actually apply
@fastify/static's default Cache-Control management overwrote the
setHeaders values with 'public, max-age=0' (verified on prod after the
v2.4.8 deploy) - the README requires disabling it for custom headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:31 +02:00
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
43 changed files with 2718 additions and 1264 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.4", "version": "2.4.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.4", "version": "2.4.9",
"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.4", "version": "2.4.9",
"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,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)
@@ -795,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

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

@@ -450,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(),
); );
}); });
@@ -656,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)", () => {
@@ -685,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,
@@ -694,25 +708,26 @@ 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Č: ");
}); });

View File

@@ -184,6 +184,44 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
}); });
}); });
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)", () => {
it("frees the auto-created project's number back to the project sequence", async () => { it("frees the auto-created project's number back to the project sequence", async () => {
// The project sequence is reset in afterEach, so the preview before the // The project sequence is reset in afterEach, so the preview before the

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

@@ -15,7 +15,7 @@ import {
DndContext, DndContext,
closestCenter, closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
@@ -170,7 +170,11 @@ function SortableItemRow({
{...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>
@@ -316,7 +320,11 @@ function SortableItemRow({
{...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>
@@ -455,10 +463,16 @@ export default function DocumentItemsEditor({
}: DocumentItemsEditorProps) { }: DocumentItemsEditorProps) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 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( 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),
); );

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

@@ -25,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 {

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>

View File

@@ -207,9 +207,10 @@ export default function IssuedOrderDetail() {
name: form.supplier_name || `Dodavatel #${form.supplier_id}`, name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null, ico: null,
dic: null, dic: null,
address: null, street: null,
email: null, city: null,
phone: null, postal_code: null,
country: null,
}, },
]; ];
} }
@@ -572,7 +573,16 @@ export default function IssuedOrderDetail() {
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="/orders?tab=vydane" to="/orders?tab=vydane"

View File

@@ -620,7 +620,16 @@ export default function OfferDetail() {
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="/offers" to="/offers"

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,
@@ -267,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({
@@ -535,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">
@@ -596,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

@@ -13,6 +13,8 @@ import {
escapeHtml, escapeHtml,
cleanQuillHtml, cleanQuillHtml,
buildQuillIndentCss, buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared"; } from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
@@ -232,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(
@@ -264,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) {
@@ -300,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;
@@ -472,17 +516,41 @@ 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
const indentCSS = buildQuillIndentCss(); const indentCSS = buildQuillIndentCss();
@@ -496,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 {
@@ -509,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; }
@@ -725,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 {
@@ -797,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; }
@@ -833,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; }
@@ -944,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">
@@ -1018,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

@@ -11,6 +11,8 @@ import {
escapeHtml, escapeHtml,
cleanQuillHtml, cleanQuillHtml,
buildQuillIndentCss, buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared"; } from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
@@ -115,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,
@@ -128,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 };
} }
@@ -226,13 +267,28 @@ export function buildIssuedOrderPdfFooter(
const t = translations[lang]; const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page"; const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of"; const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;"> 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: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; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div> <div style="flex:1;"></div>
</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[],
@@ -249,17 +305,11 @@ export function renderIssuedOrderHtml(
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).
@@ -350,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 {
@@ -621,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; }
@@ -795,6 +853,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
: 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), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
}); });
nasOrdersManager.saveIssuedPdf( nasOrdersManager.saveIssuedPdf(
@@ -807,6 +870,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
} }
const pdf = await htmlToPdf(html, { const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
}); });
const filename = `Objednavka-${order.po_number || String(id)}.pdf`; const filename = `Objednavka-${order.po_number || String(id)}.pdf`;

View File

@@ -21,8 +21,10 @@ import {
import { import {
renderIssuedOrderHtml, renderIssuedOrderHtml,
buildIssuedOrderPdfFooter, buildIssuedOrderPdfFooter,
buildIssuedOrderPdfHeader,
} from "./issued-orders-pdf"; } from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
import { nasOrdersManager } from "../../services/nas-financials-manager"; import { nasOrdersManager } from "../../services/nas-financials-manager";
import { contentDisposition } from "../../utils/content-disposition"; import { contentDisposition } from "../../utils/content-disposition";
@@ -111,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" }],
@@ -310,6 +313,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
sections, sections,
); );
const pdf = await htmlToPdf(html, { const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
}); });

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

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

@@ -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,35 @@ async function start() {
root: path.join(__dirname, "..", "dist-client"), root: path.join(__dirname, "..", "dist-client"),
prefix: "/", prefix: "/",
wildcard: false, wildcard: false,
// The plugin's own Cache-Control management must be OFF or it
// overwrites the setHeaders values below with "public, max-age=0"
// (README: "To provide a custom Cache-Control header, set this option
// to false"). ETags stay on, so no-cache still revalidates cheaply.
cacheControl: 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

@@ -231,9 +231,10 @@ 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" } },

View File

@@ -761,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

@@ -74,6 +74,15 @@ export interface HtmlToPdfOptions {
* Chromium's default date/title header. * Chromium's default date/title header.
*/ */
footerTemplate?: string; 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( export async function htmlToPdf(
@@ -95,16 +104,16 @@ export async function htmlToPdf(
format: "A4", format: "A4",
printBackground: true, printBackground: true,
margin: { margin: {
top: "10mm", top: options.headerTemplate ? "32mm" : "10mm",
bottom: options.footerTemplate ? "18mm" : "10mm", bottom: options.footerTemplate ? "18mm" : "10mm",
left: "10mm", left: "10mm",
right: "10mm", right: "10mm",
}, },
...(options.footerTemplate ...(options.footerTemplate || options.headerTemplate
? { ? {
displayHeaderFooter: true, displayHeaderFooter: true,
headerTemplate: "<span></span>", headerTemplate: options.headerTemplate || "<span></span>",
footerTemplate: options.footerTemplate, footerTemplate: options.footerTemplate || "<span></span>",
} }
: {}), : {}),
timeout: 15_000, timeout: 15_000,

View File

@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
.replace(/"/g, "&quot;"); .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 * Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles, * handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,