Compare commits

...

17 Commits

Author SHA1 Message Date
BOHA
2dbacc3bec chore(release): v2.4.10 - mobile UX fixes (plan grid, Odin, toolbar)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:54:36 +02:00
BOHA
c21f02e5d1 fix(mobile): plan landscape collapse + person picker, Odin page scroll, plan toolbar stacking
- PlanGrid maxHeight gets a 360px floor: calc(100dvh - 240px) left
  <160px in phone landscape and the rows vanished under the sticky
  header
- Phones get a person picker above the plan grid (one column at a
  time, defaults to the logged-in user) - the multi-person grid never
  fit a portrait viewport; desktop unchanged
- Odin chat height uses 100svh instead of 100dvh: dvh grows live as
  Android Chrome collapses its address bar, so the page always allowed
  a chrome-height scroll; svh sizes for the bar-visible viewport
  (verified zero overflow at 390x844 via Playwright)
- Plan toolbar stacks into full-width rows on xs (headerActionsSx
  pattern from the detail pages); Dnes/arrows and Tyden/Mesic stay
  paired on one row each

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:53:52 +02:00
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
46 changed files with 2932 additions and 1421 deletions

761
CLAUDE.md
View File

@@ -1,25 +1,26 @@
# CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
---
## Tech Stack
| Layer | Technology |
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
| Layer | Technology |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript (strict, all tsconfigs) |
| HTTP Framework | Fastify 5 |
| ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4 |
| Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
| Testing | Vitest + Supertest against a real `app_test` DB |
| PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
| Email / Cron | nodemailer / node-cron |
| AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
(Exact versions live in `package.json` — don't trust any pinned here.)
---
@@ -27,34 +28,28 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
```
src/
├── server.ts # Fastify server entry point — plugins, routes, error handler
├── server.ts # Fastify entry — plugins, routes, error handler
├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
├── schemas/ # Zod validation schemas (one file per entity)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
# security.ts (CSP, HSTS, security headers)
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
├── services/ # Business logic (no classes, exported functions, own Prisma)
├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
├── admin/ # React 19 + MUI v7 frontend
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
│ ├── theme.ts / GlobalStyles.tsx
│ ├── ui/ # MUI component kit — pages import from here
│ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── pages/ # One file per page/feature
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
│ ├── lib/queries/ # React Query options + useApiMutation
│ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
│ └── utils/ # api.ts (apiFetch with token refresh), formatters
└── __tests__/ # Vitest suites (server-side only; real app_test DB)
prisma/
├── schema.prisma # 52 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
prisma/ # schema.prisma (snake_case columns) + migrations/
dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
```
---
@@ -62,508 +57,364 @@ dist-client/ # Built frontend (Vite, ES2020)
## Commands
```bash
# Development
npm run dev # Starts server in watch mode (manage frontend separately)
npm run dev:server # tsx watch src/server.ts
npm run dev:client # Vite dev server
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass) — runs against the app_test DB via .env.test
npm run test:watch # vitest watch
# Quality gates
npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
npm run build # server + client
npm test # vitest run — against app_test via .env.test
npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing)
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 0 errors
npm run format # prettier --write .
# Database
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
npx prisma generate # after every schema change (client is not committed)
npx prisma studio # DB browser
```
**Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
**Before any migration, ask the user to stop their dev server and wait for
confirmation** (the running server holds DB connections).
---
## Environment Variables
Required:
Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
```
DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>
```
Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
`ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
`REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files),
`NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees),
`MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`,
`APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it).
Optional (with defaults):
```
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
```
Use `.env` for dev, `.env.test` for tests.
`.env` for dev, `.env.test` for tests (both gitignored — **never edit env
files; the user manages them**).
---
## Architecture & Key Patterns
### Request Flow
### Request flow
```
Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth()
Zod schema validation (parseBody helper)
→ Route handler
Service function
→ Prisma
→ success(reply, data) or error(reply, message, status)
→ requirePermission() / requireAnyPermission()
parseBody(Schema) / parseId(param)
→ Route handler → Service function → Prisma
success(reply, data) | error(reply, czechMessage, status)
```
### Response Format
### Response format
All responses use this shape:
`{ success: true, data, message?, pagination? }` on success,
`{ success: false, error }` on failure. Always the `success()`/`error()`/
paginated helpers — never raw `reply.send()`.
```typescript
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
### Services
// Error
{ success: false, error: string }
```
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
### Service Pattern
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
Plain exported async functions; services own Prisma, routes stay thin.
Preferred error shape: return **token literals** (`"not_found"`,
`"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map
tokens to Czech messages + HTTP codes (the offers/issued-orders modules are
the reference). Legacy services return `{ error: czech, status }` — fine to
keep until touched; never the discriminated-union style. Respect soft-delete
(`is_deleted: false`) in reads where the model has it.
### Permissions
```typescript
// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
"preHandler",
requirePermission("invoices.view", "invoices.edit"),
);
`requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
role bypasses checks (shortcut: `authData.roleName === "admin"`
⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
(bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
permission opens pages read-only; an _edit_ permission unlocks fields and
save buttons.
// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit
### Audit Logging
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
`logAudit()` on every create/update/delete (and security-relevant actions)
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
descriptions for unnumbered drafts. Audit failures are non-fatal.
---
## Date & Timezone Handling (Critical Gotcha)
## Dates & Timezones (Critical — three rules)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
`Date.prototype.toJSON()` to return local time (not UTC). This means:
1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration
parity). All API date strings are local. Never assume UTC; never manually
offset. Do not remove the override.
2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date
part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of
the _previous_ day) silently shifts the queried window a day back — this was
a 14-site production bug class. Build `@db.Date` filter boundaries and
"today" writes as UTC-midnight instants of the LOCAL calendar day:
`new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from
`src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare
`new Date()` into a `@db.Date` column (stores yesterday between 00: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.
- **Never assume UTC** when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
- The override exists for PHP migration compatibility and Czech date display.
---
## Document Modules (offers · orders · issued orders · invoices)
**Business rules (user/accountant decisions — do not revert):**
- **VAT exists ONLY on invoices and received invoices.** Offers, order
confirmations and issued orders are NOT tax documents: net prices only, one
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
from an order prefill the company default VAT rate.
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
offers take customers. Issued orders share the `orders._` permission family
(deliberate).
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
design; invoices + issued orders share the red-accent (#de3a3a) family.
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
orders deliberately have NO scope-template picker, NO item templates, NO
duplicate action.
**Platform conventions:**
- **Deferred numbering:** drafts carry a NULL document number
(nullable-unique column); the sequence number is consumed in-transaction on
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
released-if-highest on delete. Never hardcode numbering; previews use the
collision-advancing helpers.
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
responses return `valid_transitions` and the UI renders transition buttons
from it. Field edits outside the editable states (offers: draft/active;
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
payloads still pass.
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
enriches `locked_by {user_id, username, full_name}` only when fresh and
held by another user; client side is the shared `useDocumentLock` hook +
`LockBanner`.
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
so the UI hides PDF buttons on drafts. Numbered-document archives write to
a deterministic path and **overwrite in place** — never uniquePath `_N`
suffixes. The issued-order PDF gets language from the document's `language`
column and carries a Puppeteer `footerTemplate` footer on every page
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
- **Shared modules — extend, never fork local copies:**
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
diacritics in headers make Node throw 500s).
---
## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
hex formats both supported); encrypted backup codes with their own
`/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
enrollment. Login flow: password → (if enrolled) single-use 5-min
`loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
QR is generated locally with the `qrcode` package (never an external QR URL —
CSP blocks it and it would leak the secret).
---
## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy``DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`.
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
- The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
unless `DATABASE_URL` names a `*test*` database.
- **After any schema change, apply migrations to the test DB too** or the
suite fails: set `DATABASE_URL` to the app_test URL and run
`npx prisma migrate deploy`.
- To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
`npx tsx prisma/seed.ts`.
- **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
sequence deadlocks. Tests are type-checked by `tsc -b`.
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
cleanup; FK-safe deletion order. New features get tests in
`src/__tests__/<feature>.test.ts`.
---
## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
- Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
toasts via `useAlert()`.
- **React Query for all fetching** (query options live in
`src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
server message through). `apiFetch` refreshes tokens proactively.
- **Rules of Hooks:** every hook runs BEFORE any early return — the
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
— prefix matching covers sub-queries), and a mutation invalidates every
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
still must invalidate their domains.
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
DB. Don't duplicate label maps.
### Query Invalidation Convention
### Styling (MUI v7 — no custom .css, no Tailwind)
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
- `["users"]` over `["users", "list"]`
- `["trips"]` over `["trips", "vehicles"]`
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
- Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/`
reach for raw `@mui/material` only for one-off layout/infra.
- Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
- Don't regress: status row tints are channel-alpha washes (never `.light`
fills — invisible text in dark mode); icon badges are solid `X.main` +
white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
`useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
fade; ConfirmDialogs stay open with `loading` during their request.
- When refactoring pages, **preserve ALL data logic verbatim** (hooks,
mutations, invalidate arrays, validation, permissions).
---
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
## AI Assistant — "Odin"
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
**Where styling lives**
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
**Building / editing pages**
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
**Conventions learned — don't regress**
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
(text-only messages get a canned reply, no API call). Backend
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger
in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
PDF → `extract-invoices` (structured output) → review cards → existing
`POST /received-invoices`. **`received_invoices.amount` is GROSS
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
notes live in `docs/superpowers/specs/`.
---
## AI Assistant — "Odin" (shipped v2.1.5)
## Database & Migrations
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase.
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
soft-delete via `is_deleted` where present; `number_sequences` owns all
document numbering.
- **SDK / model:** `@anthropic-ai/sdk``claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`).
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
- **Frontend:** `src/admin/pages/Odin.tsx``src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces``Newsreader` font added to `index.html`.
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
**Golden rules:**
---
- **NEVER `prisma db push`** — it bypasses migration history and causes drift.
- **Every DB change is a tracked migration** — schema, permission/seed rows,
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
`APP_ENV=production`) — prod baselines belong in migrations.
## Database Conventions
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
- Lookup data, default settings, or any other row-level baseline that production needs
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
**What is NOT acceptable:**
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
**Making a schema change (this shell is non-interactive — `prisma migrate
dev` will refuse to run; use this recipe):**
```bash
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
# 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
# 2. Generate the migration SQL into a new folder:
mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
> prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
# 3. Review the SQL, then apply + regenerate:
npx prisma migrate deploy
npx prisma generate
# 4. Apply to the test DB as well (suite breaks otherwise):
# DATABASE_URL=<app_test url> npx prisma migrate deploy
# 5. Commit schema.prisma AND the migration folder together.
```
### Deploying migrations to production
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
Deployment, drift checks, hotfix-migration shipping and baselining:
see **`docs/release.md`**.
---
## Conventions (enforcedverified by the 2026-06-06 audit)
These are the unified rules across the codebase. Follow them for new code; the
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
## Enforced Conventions (single source — merged 2026-06 audits)
**Routes**
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
- **Bodies:** validate every body with `parseBody(Schema, request.body)``if ("error" in body) return error(reply, body.error, 400)`.
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
**Services**
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
- `success()`/`error()`/paginated helpers only; `parseId` for numeric params
(`if (id === null) return;`); `parseBody` for every body; permission guards
on reads AND writes; `logAudit` with old/new values on every mutation.
- Role-management writes are admin-only and re-checked (no creating or
rewriting the `admin` role).
**Schemas (Zod 4)**
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
- User-facing messages in **Czech**; identifiers/keys in English.
- `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
- **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
form-coerced fields: `numberFromForm`, `numberInRange`,
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
the single biggest historical bug class). FK ids → intIdFromForm;
quantities/prices → nonNegative/positive; bounded → numberInRange.
- The date/time helpers are deliberately **lenient** (strip trailing time
components) because edit forms re-submit `toJSON`-serialized values.
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
- Align `max()` caps with the DB column widths (an over-cap string 500s at
Prisma instead of 400ing at Zod). Update schemas derive via
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
still injects field defaults into partial payloads).
- User-facing messages in **Czech**; identifiers/tokens in English.
**Frontend**
**Determinism & concurrency**
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
- Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
columns make single-key sorts non-deterministic).
- Read-modify-write of shared rows (stock, balances, sequences) runs inside
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
writes (header + items + sections) belong in ONE transaction.
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
the checker) and catch `P2002` → 409 as backstop.
**Dates (two deliberate regimes — don't "fix" either)**
**Errors**
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
## Conventions (enforced — added by the 2026-06-09 full audit)
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
**Zod validation — shared coercion helpers are MANDATORY**
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `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.
- Never silently swallow — every catch logs (`console.*` in services; there
is no request-scoped logger there). The only allowed silent catch is an
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
---
## Known Issues & Gotchas
## Known Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
11. **Seed file is dev-only**`prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
pass unsanitized user data into Puppeteer templates; string-built HTML
(print views, PDF templates) must `escapeHtml` every interpolation.
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
with logged errors, and tests stub NAS reads.
4. **Prisma client**: regenerate after every schema change; it is not
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
6. **Czech locale hardcoded** in messages/month names — intentional.
7. **Cross-login caches:** React Query keys are user-agnostic; the query
cache is cleared on login/logout in AuthContext — keep it that way.
---
## Release Process
## Release
1. Bump version in `package.json`
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma 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.
Process, deployment commands, hotfix-migration shipping and verification
checklist: **`docs/release.md`**. Run the full test suite before tagging; the
user picks version numbers. **Never push to production or restart services
without explicit confirmation.**

96
docs/release.md Normal file
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",
"version": "2.4.4",
"version": "2.4.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.4",
"version": "2.4.10",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

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

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

@@ -7,29 +7,29 @@ datasource db {
}
model attendance {
id Int @id @default(autoincrement())
user_id Int
shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500)
notes String? @db.Text
project_id Int?
leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[]
id Int @id @default(autoincrement())
user_id Int
shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500)
notes String? @db.Text
project_id Int?
leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@ -127,54 +127,54 @@ model bank_accounts {
}
model company_settings {
id Int @id @default(autoincrement())
company_name String? @db.VarChar(255)
street String? @db.VarChar(255)
city String? @db.VarChar(255)
postal_code String? @db.VarChar(20)
country String? @db.VarChar(100)
company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText
available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
id Int @id @default(autoincrement())
company_name String? @db.VarChar(255)
street String? @db.VarChar(255)
city String? @db.VarChar(255)
postal_code String? @db.VarChar(20)
country String? @db.VarChar(100)
company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText
available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
}
model customers {
@@ -198,49 +198,49 @@ model customers {
}
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@index([invoice_id], map: "invoice_id")
}
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date
due_date DateTime? @db.Date
tax_date DateTime? @db.Date
paid_date DateTime? @db.Date
issued_by String? @db.VarChar(255)
billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
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")
id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date
due_date DateTime? @db.Date
tax_date DateTime? @db.Date
paid_date DateTime? @db.Date
issued_by String? @db.VarChar(255)
billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5)
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
invoice_sections invoice_sections[]
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@ -249,6 +249,19 @@ model invoices {
@@index([order_id], map: "order_id")
}
model invoice_sections {
id Int @id @default(autoincrement())
invoice_id Int
position Int? @default(0)
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
modified_at DateTime? @db.DateTime(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
@@index([invoice_id], map: "invoice_sections_invoice_id")
}
model item_templates {
id Int @id @default(autoincrement())
name String? @db.VarChar(255)
@@ -365,24 +378,24 @@ model orders {
}
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500)
internal_notes String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500)
internal_notes String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
issued_order_sections issued_order_sections[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
@@ -449,29 +462,29 @@ model project_notes {
}
model projects {
id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
quotation_id Int?
order_id Int?
status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date
end_date DateTime? @db.Date
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
quotation_id Int?
order_id Int?
status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date
end_date DateTime? @db.Date
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
project_notes project_notes[]
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -496,26 +509,26 @@ model quotation_items {
}
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
@@index([customer_id], map: "customer_id")
@@index([quotation_number], map: "idx_quotations_number")
@@ -589,14 +602,14 @@ model roles {
}
model scope_sections {
id Int @id @default(autoincrement())
quotation_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)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
id Int @id @default(autoincrement())
quotation_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)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")
}
@@ -665,38 +678,38 @@ model trips {
}
model users {
id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255)
first_name String @db.VarChar(50)
last_name String @db.VarChar(50)
id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255)
first_name String @db.VarChar(50)
last_name String @db.VarChar(50)
role_id Int?
is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[]
trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
work_plan_entries_owned work_plan_entries[]
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
work_plan_overrides_owned work_plan_overrides[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active")
@@index([role_id], map: "idx_users_role_id")
@@ -791,18 +804,18 @@ model sklad_categories {
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
street String? @db.VarChar(255)
city String? @db.VarChar(255)
postal_code String? @db.VarChar(20)
country String? @db.VarChar(100)
custom_fields String? @db.LongText
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
issued_orders issued_orders[]
@@ -819,7 +832,7 @@ model sklad_locations {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
items sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[]
@@ -828,19 +841,19 @@ model sklad_locations {
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
@@ -855,50 +868,50 @@ model sklad_items {
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[]
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by")
@@ -906,50 +919,50 @@ model sklad_receipts {
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches?
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[]
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by")
@@ -957,72 +970,72 @@ model sklad_issues {
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[]
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[]
items sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
@@ -1040,20 +1053,20 @@ model plan_categories {
}
model work_plan_entries {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
user_id Int
date_from DateTime @db.Date
date_to DateTime @db.Date
date_from DateTime @db.Date
date_to DateTime @db.Date
project_id Int?
category String @default("work") @db.VarChar(50)
note String @db.VarChar(500)
category String @default("work") @db.VarChar(50)
note String @db.VarChar(500)
created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, date_from], map: "idx_wpe_user_from")
@@index([user_id, date_to], map: "idx_wpe_user_to")
@@ -1062,19 +1075,19 @@ model work_plan_entries {
}
model work_plan_overrides {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
user_id Int
shift_date DateTime @db.Date
shift_date DateTime @db.Date
project_id Int?
category String @db.VarChar(50)
note String @db.VarChar(500)
category String @db.VarChar(50)
note String @db.VarChar(500)
created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, shift_date], map: "idx_wpo_user_date")
@@index([shift_date], map: "idx_wpo_date")

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,
createInvoice,
updateInvoice,
getInvoice,
listInvoices,
getInvoiceListTotals,
} 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
// `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);
// 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 } });
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 */
/* -------------------------------------------------------------------------- */

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);
expect(row.name).toBe("io_test_Aktivní dodavatel");
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(
["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" };
// sklad_suppliers shape: single Text address blob + ico/dic columns.
// sklad_suppliers shape: structured address + ico/dic columns.
const supplier = {
name: "Dodavatel s.r.o.",
ico: "12345678",
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)", () => {
@@ -685,7 +699,7 @@ describe("renderIssuedOrderHtml", () => {
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(
order,
items,
@@ -694,25 +708,26 @@ describe("renderIssuedOrderHtml", () => {
"cs",
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">190 00 Praha</div>');
expect(html).toContain('<div class="address-line">Česká republika</div>');
expect(html).toContain("IČ: 12345678");
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(
order,
items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
{ name: "Jednořádkový", street: "Ulice 1", city: "Praha" },
null,
"cs",
issuer,
);
expect(html).toContain(
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
);
expect(html).toContain('<div class="address-line">Ulice 1</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("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)", () => {
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

View File

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

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

@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { styled } from "@mui/material/styles";
import { styled, useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import {
GridData,
@@ -11,7 +12,8 @@ import {
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips";
import { LoadingState } from "../ui";
import { LoadingState, Select, Field } from "../ui";
import { useAuth } from "../context/AuthContext";
import { fonts } from "../theme";
/**
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.vars!.palette.divider}`,
borderRadius: 14,
boxShadow: theme.shadows[2],
maxHeight: "calc(100dvh - 240px)",
// Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
// ~360-400px, so the bare calc left <160px and the cells disappeared
// under the sticky header. The grid scrolls internally either way.
maxHeight: "max(calc(100dvh - 240px), 360px)",
isolation: "isolate",
"& .plan-grid": {
@@ -464,6 +469,15 @@ export default function PlanGrid({
pulseKey,
onCellClick,
}: Props) {
const theme = useTheme();
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
// a person picker shows ONE column at a time (Datum + selected person).
// Desktop/tablet keeps all columns.
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { user: authUser } = useAuth();
// null = "no explicit choice yet" → defaults to the logged-in user when
// they are in the plan, else the first person.
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]);
// Index projects by id once per projects change instead of a linear
@@ -483,7 +497,15 @@ export default function PlanGrid({
);
if (!data) return <LoadingState />;
const users = data.users;
const allUsers = data.users;
// Mobile: narrow to the picked person (derived, not stored — a stale pick
// after the user list changes falls back gracefully).
const mobileUser =
allUsers.find((u) => u.id === mobileUserId) ??
allUsers.find((u) => u.id === authUser?.id) ??
allUsers[0];
const users =
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
// pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations
// don't restart unless the keyframe applies to a fresh element/class
@@ -493,156 +515,172 @@ export default function PlanGrid({
: undefined;
return (
<PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
<>
{isMobile && allUsers.length > 1 && (
<Box sx={{ mb: 1.5 }}>
<Field label="Osoba">
<Select
value={String(mobileUser?.id ?? "")}
onChange={(value) => setMobileUserId(Number(value))}
options={allUsers.map((u) => ({
value: String(u.id),
label: u.full_name,
}))}
/>
</Field>
</Box>
)}
<PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
table — `min-width` on `<th>`/`<td>` is just a floor that
`table-layout: auto` happily blows past. The first column
gets a fixed width via the col element so the date stamp
doesn't get stretched to share space with person columns. */}
<colgroup>
<col className="plan-grid-date-col" />
{users.map((u) => (
<col key={u.id} />
))}
</colgroup>
<thead>
<tr>
<th className="plan-grid-date-col">Datum</th>
{users.map((u) => {
const { first, last } = splitName(u.full_name);
return (
<th key={u.id}>
<span className="plan-person-head">
<span className="plan-person-dot" aria-hidden />
<span className="plan-person-name">
<strong title={u.full_name}>
{first}
{last ? ` ${last}` : ""}
</strong>
{shortRole(u.role_name) && (
<small>{shortRole(u.role_name)}</small>
)}
<colgroup>
<col className="plan-grid-date-col" />
{users.map((u) => (
<col key={u.id} />
))}
</colgroup>
<thead>
<tr>
<th className="plan-grid-date-col">Datum</th>
{users.map((u) => {
const { first, last } = splitName(u.full_name);
return (
<th key={u.id}>
<span className="plan-person-head">
<span className="plan-person-dot" aria-hidden />
<span className="plan-person-name">
<strong title={u.full_name}>
{first}
{last ? ` ${last}` : ""}
</strong>
{shortRole(u.role_name) && (
<small>{shortRole(u.role_name)}</small>
)}
</span>
</span>
</span>
</th>
</th>
);
})}
</tr>
</thead>
<tbody>
{days.map((date) => {
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
);
})}
</tr>
</thead>
<tbody>
{days.map((date) => {
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</PlanGridRoot>
</tbody>
</table>
</PlanGridRoot>
</>
);
}

View File

@@ -15,7 +15,7 @@ import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
@@ -170,7 +170,11 @@ function SortableItemRow({
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
>
{DragIcon}
</IconButton>
@@ -316,7 +320,11 @@ function SortableItemRow({
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
>
{DragIcon}
</IconButton>
@@ -455,10 +463,16 @@ export default function DocumentItemsEditor({
}: DocumentItemsEditorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
// pointer events and its 5px distance constraint fires before the
// TouchSensor's long-press delay — the browser then claims the gesture for
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
// TouchSensor handles touch via long-press; the drag handles additionally
// carry touch-action: none so the browser never starts a scroll from them.
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
activationConstraint: { delay: 300, tolerance: 8 },
}),
useSensor(KeyboardSensor),
);

View File

@@ -422,7 +422,12 @@ export default function OdinChat() {
return (
<Box
sx={{
height: "calc(100dvh - 100px)",
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
// dvh-sized full-height layout always lets the page scroll by the
// browser-chrome height. svh sizes for the bar-visible viewport — the
// page never scrolls and the chat's message list scrolls internally.
// Desktop: svh === vh.
height: "calc(100svh - 100px)",
display: "flex",
border: 1,
borderColor: "divider",

View File

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

View File

@@ -25,9 +25,10 @@ export interface Supplier {
name: string;
ico: string | null;
dic: string | null;
address: string | null;
email: string | null;
phone: string | null;
street: string | null;
city: string | null;
postal_code: string | null;
country: string | null;
}
export interface IssuedOrderItem {

View File

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

View File

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

View File

@@ -24,11 +24,14 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
@@ -198,7 +201,7 @@ interface InvoiceForm {
constant_symbol: string;
issued_by: string;
billing_text: string;
notes: string;
internal_notes: string;
language: string;
bank_account_id: number | string;
bank_name: string;
@@ -275,7 +278,11 @@ function SortableInvoiceRow({
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
>
{DragIcon}
</IconButton>
@@ -385,7 +392,7 @@ function SortableInvoiceRow({
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
sx={{ cursor: "grab", color: "text.secondary", touchAction: "none" }}
>
{DragIcon}
</IconButton>
@@ -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(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
activationConstraint: { delay: 300, tolerance: 8 },
}),
useSensor(KeyboardSensor),
);
@@ -540,7 +553,7 @@ export default function InvoiceDetail() {
constant_symbol: "0308",
issued_by: user?.fullName || "",
billing_text: "",
notes: "",
internal_notes: "",
language: "cs",
bank_account_id: "",
bank_name: "",
@@ -550,6 +563,9 @@ export default function InvoiceDetail() {
}));
const [dueDays, setDueDays] = useState(14);
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]);
const [items, setItems] = useState<InvoiceItem[]>(() => [
{
_key: "inv-1",
@@ -626,7 +642,6 @@ export default function InvoiceDetail() {
});
// ─── Edit mode state ───
const [notes, setNotes] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
@@ -696,7 +711,7 @@ export default function InvoiceDetail() {
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
notes: inv.notes || "",
internal_notes: inv.internal_notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: inv.bank_name || "",
@@ -705,7 +720,6 @@ export default function InvoiceDetail() {
bank_account: inv.bank_account || "",
};
setForm(formData);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates
@@ -741,10 +755,22 @@ export default function InvoiceDetail() {
setItems(mappedItems);
}
// Populate sections from existing invoice
const mappedSections =
Array.isArray(inv.sections) && inv.sections.length > 0
? inv.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [];
setSections(mappedSections);
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setDataReady(true);
@@ -841,13 +867,15 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
}, [form, items]);
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
@@ -1005,6 +1033,12 @@ export default function InvoiceDetail() {
unit_price: Number(item.unit_price) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: i,
})),
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1017,7 +1051,7 @@ export default function InvoiceDetail() {
const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items });
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
@@ -1129,7 +1163,16 @@ export default function InvoiceDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/invoices?tab=issued"
@@ -1148,7 +1191,13 @@ export default function InvoiceDetail() {
}}
>
<Typography variant="h4">
Faktura {documentNumberLabel(invoice.invoice_number)}
Faktura
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{documentNumberLabel(invoice.invoice_number)}
</Box>
</Typography>
<StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)}
@@ -1281,11 +1330,6 @@ export default function InvoiceDetail() {
<Field label="Variabilní symbol">
<Typography variant="body2">{invoice.invoice_number}</Typography>
</Field>
<Field label="Vystavil">
<Typography variant="body2">
{invoice.issued_by || "—"}
</Typography>
</Field>
</Box>
{invoice.paid_date && (
<Box sx={{ mt: 2 }}>
@@ -1597,14 +1641,55 @@ export default function InvoiceDetail() {
</Box>
</Card>
{/* Notes (read-only) */}
{/* Obsah sections (read-only) — shown only when any section has content */}
{(invoice.sections ?? []).some(
(s) =>
(s.title || "").trim() ||
(s.title_cz || "").trim() ||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
) && (
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Obsah
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{(invoice.sections ?? []).map((s, idx) => (
<Box key={s.id ?? idx}>
{((invoice.language === "cs"
? s.title_cz || s.title
: s.title) ||
"") && (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{invoice.language === "cs"
? s.title_cz || s.title
: s.title}
</Typography>
)}
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(s.content || ""),
}}
/>
)}
</Box>
))}
</Box>
</Card>
)}
{/* Internal notes (read-only, never printed) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Veřejné poznámky na faktuře
Interní poznámky
</Typography>
{notes && notes.trim() && notes !== "<p><br></p>" ? (
{invoice.internal_notes &&
invoice.internal_notes.trim() &&
invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes),
}}
/>
) : (
<Typography variant="body2" color="text.secondary">
@@ -1648,7 +1733,16 @@ export default function InvoiceDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/invoices?tab=issued"
@@ -1659,43 +1753,44 @@ export default function InvoiceDetail() {
Zpět
</Button>
<Box>
{isEdit && invoice ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Faktura {documentNumberLabel(invoice.invoice_number)}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
{isEdit ? "Faktura" : "Nová faktura"}
{/* Edit mode: a draft has no number yet → show "Koncept".
Create mode: show the previewed next-number when available. */}
{(isEdit || invoiceNumber) && (
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{isEdit
? documentNumberLabel(invoice?.invoice_number)
: invoiceNumber}
</Box>
)}
</Typography>
{isEdit && invoice && (
<StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)}
color={statusColor(INVOICE_STATUS, invoice.status)}
/>
</Box>
) : (
<>
<Typography variant="h4">
Nová faktura{" "}
{invoiceNumber && (
<Box component="span" sx={{ color: "text.secondary" }}>
({invoiceNumber})
</Box>
)}
</Typography>
{fromOrderId && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Z objednávky
</Typography>
)}
</>
)}
</Box>
{!isEdit && fromOrderId && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Z objednávky
</Typography>
)}
</Box>
</Box>
@@ -1866,37 +1961,19 @@ export default function InvoiceDetail() {
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Číslo faktury">
<TextField
value={invoiceNumber}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
<Field label="Odběratel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
error={errors.customer_id}
placeholder="Hledat zákazníka (název, IČ)..."
/>
</Field>
<Field label="Vystavil">
<TextField
value={form.issued_by}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
</Box>
{/* Číslo faktury and Vystavil are not form fields anymore: the
number lives in the page header (deferred numbering — "Koncept"
until finalize) and issued_by is auto-filled from the logged-in
user and still submitted with the payload (the PDF prints it). */}
<Field label="Odběratel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
error={errors.customer_id}
placeholder="Hledat zákazníka (název, IČ)..."
/>
</Field>
<Field label="Text fakturace (na PDF)">
<TextField
@@ -2242,16 +2319,26 @@ export default function InvoiceDetail() {
</Box>
</Card>
{/* Notes */}
{/* Rich-text PDF sections — printed after the items (like orders) */}
<SectionsEditor
sections={sections}
onChange={setSections}
readOnly={false}
title="Obsah"
language={form.language}
/>
{/* Internal notes */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Veřejné poznámky na faktuře
</Typography>
<RichEditor
value={form.notes}
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
placeholder="Poznámky zobrazené na faktuře..."
/>
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
<RichEditor
value={form.internal_notes}
onChange={(val) =>
setForm((prev) => ({ ...prev, internal_notes: val }))
}
placeholder="Interní poznámky..."
/>
</Field>
</Card>
</form>

View File

@@ -207,9 +207,10 @@ export default function IssuedOrderDetail() {
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null,
dic: null,
address: null,
email: null,
phone: null,
street: null,
city: null,
postal_code: null,
country: null,
},
];
}
@@ -572,7 +573,16 @@ export default function IssuedOrderDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: long document titles must drop below the Zpět button on
phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/orders?tab=vydane"

View File

@@ -620,7 +620,16 @@ export default function OfferDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/offers"

View File

@@ -12,6 +12,7 @@ import {
type CustomField,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import {
Button,
Card,
@@ -267,6 +268,21 @@ export default function OffersCustomers() {
return key;
};
// ARES prefill — overwrite the company fields with registry data (the
// button is an explicit user action, so overwriting is the expected UX).
const fillFromAres = (c: AresCompany) => {
setForm((prev) => ({
...prev,
name: c.name || prev.name,
street: c.street,
city: c.city,
postal_code: c.postal_code,
country: c.country,
company_id: c.ico,
vat_id: c.dic || "",
}));
};
const openCreateModal = () => {
setEditingCustomer(null);
setForm({
@@ -535,6 +551,15 @@ export default function OffersCustomers() {
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/>
</Field>
<Field label="Ulice">
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
company_id: e.target.value,
}))
}
InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.company_id}
onFill={fillFromAres}
/>
),
}}
/>
</Field>
<Field label="DIČ">

View File

@@ -667,19 +667,29 @@ export default function PlanWork() {
</Box>
)}
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
the detail pages); sm+ keeps the single wrapping toolbar row. The
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
full-width row each. */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
flexDirection: { xs: "column", sm: "row" },
flexWrap: { sm: "wrap" },
alignItems: { xs: "stretch", sm: "center" },
gap: 1.5,
mb: 2,
}}
>
{/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={goToToday}
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
>
Dnes
</Button>
<Button
@@ -701,8 +711,8 @@ export default function PlanWork() {
</Box>
<Box
sx={{
flex: 1,
minWidth: 220,
flex: { sm: 1 },
minWidth: { sm: 220 },
display: "flex",
justifyContent: "center",
}}
@@ -727,7 +737,11 @@ export default function PlanWork() {
<Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
sx={{
display: "flex",
gap: 1,
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
}}
>
<Button
variant={view === "week" ? "contained" : "outlined"}

View File

@@ -34,6 +34,8 @@ import {
CheckboxField,
LoadingState,
PageEnter,
Tabs,
type TabDef,
type DataColumn,
} from "../ui";
@@ -54,6 +56,12 @@ const STATUS_COLORS: Record<
zruseny: "default",
};
// Status filter tabs (mirrors the offers page): "" = all.
const STATUS_TABS: TabDef[] = [
{ value: "", label: "Všechny" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
interface Project {
id: number;
project_number: string;
@@ -120,6 +128,7 @@ export default function Projects() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
@@ -230,7 +239,13 @@ export default function Projects() {
isPending,
isFetching,
} = usePaginatedQuery<Project>(
projectListOptions({ search: debouncedSearch, sort, order, page }),
projectListOptions({
search: debouncedSearch,
sort,
order,
page,
status: statusFilter || undefined,
}),
);
if (!hasPermission("projects.view")) return <Forbidden />;
@@ -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 (
<PageEnter>
<Box
@@ -392,6 +432,17 @@ export default function Projects() {
)}
</Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={STATUS_TABS}
/>
</Box>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
@@ -411,19 +462,31 @@ export default function Projects() {
columns={columns}
rows={projects}
rowKey={(p) => p.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty.
</Typography>
<Typography variant="body2" color="text.secondary">
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
tlačítkem Přidat projekt".
</Typography>
</Box>
isFiltered ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Žádné projekty neodpovídají filtru.
</Typography>
<Typography variant="body2" color="text.secondary">
Zkuste změnit filtr nebo hledaný výraz.
</Typography>
</Box>
) : (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty.
</Typography>
<Typography variant="body2" color="text.secondary">
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
tlačítkem Přidat projekt".
</Typography>
</Box>
)
}
/>
<Pagination

View File

@@ -1,15 +1,17 @@
import { useState } from "react";
import { useState, useRef } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
type WarehouseSupplierCustomField,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
@@ -21,6 +23,7 @@ import {
ConfirmDialog,
Field,
TextField,
CheckboxField,
StatusChip,
PageHeader,
PageEnter,
@@ -32,17 +35,29 @@ import {
const API_BASE = "/api/admin/warehouse/suppliers";
// Mirror of the customers modal (minus the PDF field-order picker): the same
// company fields + "Vlastní pole"; contact person/e-mail/phone live as custom
// fields since the dedicated columns were dropped.
interface SupplierForm {
name: string;
ico: string;
dic: string;
contact_person: string;
email: string;
phone: string;
address: string;
notes: string;
street: string;
city: string;
postal_code: string;
country: string;
}
const EMPTY_SUPPLIER_FORM: SupplierForm = {
name: "",
ico: "",
dic: "",
street: "",
city: "",
postal_code: "",
country: "",
};
const PER_PAGE = 20;
const PlusIcon = (
@@ -88,6 +103,32 @@ const DeleteIcon = (
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const SmallPlusIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
export default function WarehouseSuppliers() {
const alert = useAlert();
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null);
const [form, setForm] = useState<SupplierForm>({
name: "",
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
const [customFields, setCustomFields] = useState<
WarehouseSupplierCustomField[]
>([]);
const customFieldKeyCounter = useRef(0);
const [errors, setErrors] = useState<Record<string, string>>({});
const [deactivateConfirm, setDeactivateConfirm] = useState<{
@@ -131,7 +167,13 @@ export default function WarehouseSuppliers() {
}>({ show: false, supplier: null });
const submitMutation = useApiMutation<
SupplierForm,
SupplierForm & {
custom_fields: Array<{
name: string;
value: string;
showLabel?: boolean;
}>;
},
{ id?: number; message?: string }
>({
url: () =>
@@ -174,18 +216,26 @@ export default function WarehouseSuppliers() {
if (!hasPermission("warehouse.manage")) return <Forbidden />;
// ARES prefill — overwrite the company fields with registry data (the
// button is an explicit user action, so overwriting is the expected UX).
const fillFromAres = (c: AresCompany) => {
setForm((prev) => ({
...prev,
name: c.name || prev.name,
ico: c.ico,
dic: c.dic || "",
street: c.street,
city: c.city,
postal_code: c.postal_code,
country: c.country,
}));
setErrors((prev) => ({ ...prev, name: "" }));
};
const openCreateModal = () => {
setEditingSupplier(null);
setForm({
name: "",
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
setForm({ ...EMPTY_SUPPLIER_FORM });
setCustomFields([]);
setErrors({});
setShowModal(true);
};
@@ -196,12 +246,19 @@ export default function WarehouseSuppliers() {
name: supplier.name,
ico: supplier.ico || "",
dic: supplier.dic || "",
contact_person: supplier.contact_person || "",
email: supplier.email || "",
phone: supplier.phone || "",
address: supplier.address || "",
notes: supplier.notes || "",
street: supplier.street || "",
city: supplier.city || "",
postal_code: supplier.postal_code || "",
country: supplier.country || "",
});
setCustomFields(
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
? supplier.custom_fields.map((f) => ({
...f,
_key: `cf-${++customFieldKeyCounter.current}`,
}))
: [],
);
setErrors({});
setShowModal(true);
};
@@ -213,7 +270,16 @@ export default function WarehouseSuppliers() {
if (Object.keys(newErrors).length > 0) return;
try {
await submitMutation.mutateAsync(form);
await submitMutation.mutateAsync({
...form,
custom_fields: customFields
.filter((f) => f.name.trim() || f.value.trim())
.map((f) => ({
name: f.name,
value: f.value,
showLabel: f.showLabel,
})),
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
@@ -281,23 +347,16 @@ export default function WarehouseSuppliers() {
render: (s) => s.dic || "—",
},
{
key: "contact_person",
header: "Kontaktní osoba",
width: "16%",
render: (s) => s.contact_person || "—",
key: "street",
header: "Ulice",
width: "18%",
render: (s) => s.street || "—",
},
{
key: "email",
header: "E-mail",
width: "16%",
render: (s) => s.email || "—",
},
{
key: "phone",
header: "Telefon",
width: "12%",
mono: true,
render: (s) => s.phone || "—",
key: "city",
header: "Město",
width: "14%",
render: (s) => s.city || "—",
},
{
key: "status",
@@ -394,13 +453,15 @@ export default function WarehouseSuppliers() {
/>
</Card>
{/* Add/Edit Modal */}
{/* Add/Edit Modal — mirror of the customers modal (minus the PDF
field-order picker) */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={submitMutation.isPending}
maxWidth="md"
>
<Field label="Název" required error={errors.name}>
<TextField
@@ -410,7 +471,53 @@ export default function WarehouseSuppliers() {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="Název dodavatele"
placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/>
</Field>
<Field label="Ulice">
<TextField
value={form.street}
onChange={(e) => setForm({ ...form, street: e.target.value })}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Město">
<TextField
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
/>
</Field>
<Field label="PSČ">
<TextField
value={form.postal_code}
onChange={(e) =>
setForm({ ...form, postal_code: e.target.value })
}
/>
</Field>
</Box>
<Field label="Země">
<TextField
value={form.country}
onChange={(e) => setForm({ ...form, country: e.target.value })}
/>
</Field>
@@ -425,72 +532,129 @@ export default function WarehouseSuppliers() {
<TextField
value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })}
placeholder="12345678"
InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.ico}
onFill={fillFromAres}
/>
),
}}
/>
</Field>
<Field label="DIČ">
<TextField
value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })}
placeholder="CZ12345678"
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Kontaktní osoba">
<TextField
value={form.contact_person}
onChange={(e) =>
setForm({ ...form, contact_person: e.target.value })
}
placeholder="Jan Novák"
/>
</Field>
<Field label="E-mail">
<TextField
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="info@firma.cz"
/>
</Field>
{/* Dynamic custom fields — same editor as the customers modal */}
<Box sx={{ mt: 0.5 }}>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Vlastní pole
</Typography>
{customFields.map((field, idx) => (
<Box key={field._key} sx={{ mb: 1 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
alignItems: "flex-start",
}}
>
<TextField
label={idx === 0 ? "Název" : undefined}
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], name: e.target.value };
setCustomFields(updated);
}}
placeholder="Např. Kontakt"
/>
<Box
sx={{
display: "flex",
gap: 0.5,
alignItems: idx === 0 ? "flex-end" : "center",
}}
>
<TextField
label={idx === 0 ? "Hodnota" : undefined}
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
sx={{ flex: 1 }}
/>
<IconButton
size="small"
color="error"
onClick={() =>
setCustomFields(customFields.filter((_, i) => i !== idx))
}
title="Odebrat pole"
aria-label="Odebrat pole"
>
{RemoveIcon}
</IconButton>
</Box>
</Box>
<Box sx={{ mt: 0.5 }}>
<CheckboxField
label={
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</Typography>
}
checked={field.showLabel !== false}
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], showLabel: checked };
setCustomFields(updated);
}}
/>
</Box>
</Box>
))}
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={SmallPlusIcon}
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`,
},
])
}
sx={{ mt: 0.5 }}
>
Přidat pole
</Button>
</Box>
<Field label="Telefon">
<TextField
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+420 123 456 789"
/>
</Field>
<Field label="Adresa">
<TextField
multiline
minRows={3}
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="Ulice, město, PSČ"
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Volitelné poznámky"
/>
</Field>
</Modal>
{/* Deactivate/Delete Confirmation */}

View File

@@ -58,7 +58,13 @@ export const theme = createTheme({
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
h4: {
fontFamily: FONT_HEADING,
fontWeight: 700,
// Page/detail headlines: MUI's default 2.125rem overflows phone
// viewports (long document titles + number). Scale down on xs only.
"@media (max-width:600px)": { fontSize: "1.5rem" },
},
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },

View File

@@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App";
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(
<React.StrictMode>
<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,
UpdateCustomerSchema,
} from "../../schemas/customers.schema";
import {
encodeCustomFields,
decodeCustomFields as decodeCustomFieldsShared,
} from "../../utils/custom-fields";
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(
fields: unknown,
fieldOrder: unknown,
): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
/** Customer-shaped wrapper over the shared codec (field_order key naming). */
function decodeCustomFields(raw: string | null): {
custom_fields: unknown[];
customer_field_order: string[];
} {
if (!raw) return { custom_fields: [], customer_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
"fields" in parsed
) {
return {
custom_fields: parsed.fields || [],
customer_field_order: parsed.field_order || [],
};
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, customer_field_order: [] };
}
return { custom_fields: [], customer_field_order: [] };
} catch {
return { custom_fields: [], customer_field_order: [] };
}
const { custom_fields, field_order } = decodeCustomFieldsShared(raw);
return { custom_fields, customer_field_order: field_order };
}
export default async function customersRoutes(

View File

@@ -13,6 +13,8 @@ import {
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
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 ───────────────────────────────────────────────────────── */
export default async function invoicesPdfRoutes(
@@ -264,6 +307,11 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id },
orderBy: { position: "asc" },
});
// id tiebreak: position can repeat, keep the order deterministic.
const sections = await prisma.invoice_sections.findMany({
where: { invoice_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
@@ -300,16 +348,12 @@ export default async function invoicesPdfRoutes(
}
}
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// Logo as a data URI — used by the body header (screen/HTML view) AND
// the Puppeteer headerTemplate (templates can only load data: URLs).
const logoDataUri = logoDataUriFromSettings(settings);
const logoImg = logoDataUri
? `<img src="${logoDataUri}" class="logo" />`
: "";
const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
@@ -472,17 +516,41 @@ export default async function invoicesPdfRoutes(
}
}
const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<!-- Poznamky -->
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
// ── Sections ("Obsah") — flow INLINE right after the items/totals
// block (mirrors issued orders): no separate page, long content
// paginates naturally and the Puppeteer footer template stamps every
// page. Suppressed entirely when every section is empty (tag-stripped
// check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
// the Czech title when present; en (or a missing Czech title) falls
// back to the EN title.
const resolveSectionTitle = (s: {
title: string | null;
title_cz: string | null;
}): string =>
lang === "cs" && (s.title_cz || "").trim()
? s.title_cz || ""
: s.title || "";
// Visibility must use the SAME per-language title rule as the render
// loop — otherwise a section with only the other language's title
// would count as content and produce an empty sections block.
const visibleSections = sections.filter(
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
);
let sectionsHtml = "";
if (visibleSections.length > 0) {
sectionsHtml += '<div class="scope-sections">';
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
sectionsHtml += '<div class="scope-section">';
if (title.trim())
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
sectionsHtml += "</div>";
}
sectionsHtml += "</div>";
}
// Quill indent CSS
const indentCSS = buildQuillIndentCss();
@@ -496,7 +564,11 @@ export default async function invoicesPdfRoutes(
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
/* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the issued-order PDF. */
margin: 32mm 12mm 18mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -509,11 +581,19 @@ export default async function invoicesPdfRoutes(
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
/* Printable area with the Puppeteer header (32mm top) + footer (18mm
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
min-height: calc(297mm - 51mm);
}
.invoice-content { flex: 1 1 auto; }
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
at the page bottom; when the content overflows, break-inside moves the
whole block to the next page as one unit instead of stranding the notice
on the previous page. */
.invoice-footer {
flex-shrink: 0;
break-inside: avoid;
}
.accent { color: #de3a3a; }
@@ -725,13 +805,8 @@ export default async function invoicesPdfRoutes(
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
every page — same as the issued-order PDF; no body block. */
/* Upozorneni */
.notice {
@@ -797,32 +872,42 @@ export default async function invoicesPdfRoutes(
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
stranu (mirrors issued-orders-pdf). */
.scope-sections {
margin-top: 6mm;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
.section-content {
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
.section-content h2 { font-size: 18px; }
.section-content h3 { font-size: 16px; }
.section-content h4 { font-size: 15px; }
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -833,6 +918,10 @@ ${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
}
@media screen {
html { background: #525659; }
@@ -944,16 +1033,12 @@ ${indentCSS}
</div>
</div>
${notesHtml}
${sectionsHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ""}
</div>
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
<!-- Upozorneni -->
<div class="notice">
@@ -1018,7 +1103,20 @@ ${indentCSS}
? new Date(invoice.issue_date)
: new Date();
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
const pdfBuffer = await htmlToPdf(html);
// Per-page "Vystavil + Strana X z Y" footer (same as issued
// orders); the supplier e-mail rides along on the left (it used to
// print under the body Vystavil block).
const issuerLine = `${invoice.issued_by || ""}${
suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : ""
}`;
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildInvoicePdfHeader(
lang,
logoDataUri,
invoice.invoice_number || "",
),
footerTemplate: buildInvoicePdfFooter(lang, issuerLine),
});
nasInvoicesManager.saveIssuedPdf(
invoice.invoice_number!,
issueDate.getFullYear(),

View File

@@ -11,6 +11,8 @@ import {
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -115,11 +117,12 @@ function buildAddressLines(
}
/**
* Address block for the sklad_suppliers counterparty. Unlike customers (which
* have structured street/city/postal columns), suppliers.address is a single
* Text blob — split it on newlines into one rendered line each (a blob without
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
* columns, prefixed with the same translated labels the customer block used.
* Address block for the sklad_suppliers counterparty — full customer model
* (structured street/city/postal_code/country + custom_fields JSON; the old
* dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
* the supplier's ico/dic columns, prefixed with the same translated labels
* the customer block uses; custom fields render as appended lines in array
* order (suppliers have no PDF field-order picker).
*/
function buildSupplierLines(
supplier: Record<string, unknown> | null,
@@ -128,14 +131,52 @@ function buildSupplierLines(
if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || "");
const lines: string[] = [];
if (supplier.address) {
for (const part of String(supplier.address).split(/\r?\n/)) {
const line = part.trim();
if (line) lines.push(line);
}
}
if (supplier.street) lines.push(String(supplier.street));
const cityLine = [supplier.postal_code, supplier.city]
.filter(Boolean)
.map(String)
.join(" ")
.trim();
if (cityLine) lines.push(cityLine);
if (supplier.country) lines.push(String(supplier.country));
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
// Custom fields ("Vlastní pole") — same blob format as customers; malformed
// JSON degrades to no extra lines rather than failing the PDF.
if (supplier.custom_fields) {
let parsed: unknown;
try {
parsed =
typeof supplier.custom_fields === "string"
? JSON.parse(supplier.custom_fields)
: supplier.custom_fields;
} catch {
parsed = null;
}
const fields =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? ((parsed as Record<string, unknown>).fields as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: Array.isArray(parsed)
? (parsed as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: [];
for (const f of fields ?? []) {
const value = (f?.value || "").trim();
if (!value) continue;
const label = (f?.name || "").trim();
lines.push(
f?.showLabel !== false && label ? `${label}: ${value}` : value,
);
}
}
return { name, lines };
}
@@ -226,13 +267,28 @@ export function buildIssuedOrderPdfFooter(
const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 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:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
</div>`;
}
/**
* Repeating per-page header for the issued-order PDF — the unified
* red-accent family header (shared with invoices): logo left, red
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
* own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildIssuedOrderPdfHeader(
lang: Lang,
logoDataUri: string,
poNumber: string,
): string {
const t = translations[lang];
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
@@ -249,17 +305,11 @@ export function renderIssuedOrderHtml(
const currency = order.currency || "CZK";
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
const logoDataUri = logoDataUriFromSettings(settings);
const logoImg = logoDataUri
? `<img src="${logoDataUri}" class="logo" />`
: "";
// PO direction: our company (settings) = Odběratel (buyer);
// the sklad_suppliers record = Dodavatel (supplier).
@@ -350,7 +400,11 @@ export function renderIssuedOrderHtml(
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
/* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the invoice PDF. */
margin: 32mm 12mm 18mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -621,6 +675,10 @@ ${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
}
@media screen {
html { background: #525659; }
@@ -795,6 +853,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
nasOrdersManager.saveIssuedPdf(
@@ -807,6 +870,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
}
const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;

View File

@@ -21,8 +21,10 @@ import {
import {
renderIssuedOrderHtml,
buildIssuedOrderPdfFooter,
buildIssuedOrderPdfHeader,
} from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import { contentDisposition } from "../../utils/content-disposition";
@@ -111,9 +113,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
street: true,
city: true,
postal_code: true,
country: true,
},
// id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }],
@@ -310,6 +313,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
sections,
);
const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
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 { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import {
encodeCustomFields,
decodeCustomFields,
} from "../../utils/custom-fields";
import { parseBody } from "../../schemas/common";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
import {
@@ -226,7 +230,7 @@ export default async function warehouseRoutes(
// SUPPLIERS
// =============================================================
// GET /suppliers — paginated list, search by name/ico/contact_person
// GET /suppliers — paginated list, search by name/ico/city
fastify.get(
"/suppliers",
{ preHandler: requirePermission("warehouse.manage") },
@@ -240,7 +244,7 @@ export default async function warehouseRoutes(
where.OR = [
{ name: { 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 }),
]);
// Decode the custom_fields blob into the array shape the modal edits
// (same model as customers).
const enriched = suppliers.map((s) => ({
...s,
custom_fields: decodeCustomFields(s.custom_fields).custom_fields,
}));
return paginated(
reply,
suppliers,
enriched,
buildPaginationMeta(total, page, limit),
);
},
@@ -275,7 +286,10 @@ export default async function warehouseRoutes(
});
if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
return success(reply, supplier);
return success(reply, {
...supplier,
custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields,
});
},
);
@@ -293,11 +307,12 @@ export default async function warehouseRoutes(
name: body.name,
ico: body.ico ?? null,
dic: body.dic ?? null,
contact_person: body.contact_person ?? null,
email: body.email ?? null,
phone: body.phone ?? null,
address: body.address ?? null,
notes: body.notes ?? null,
street: body.street ?? null,
city: body.city ?? null,
postal_code: body.postal_code ?? null,
country: body.country ?? null,
// Suppliers have no PDF field-order picker — order is always [].
custom_fields: encodeCustomFields(body.custom_fields, []),
is_active: body.is_active ?? true,
},
});
@@ -312,7 +327,7 @@ export default async function warehouseRoutes(
newValues: {
name: supplier.name,
ico: supplier.ico,
contact_person: supplier.contact_person,
city: supplier.city,
is_active: supplier.is_active,
},
});
@@ -342,12 +357,13 @@ export default async function warehouseRoutes(
if (body.name !== undefined) updateData.name = body.name;
if (body.ico !== undefined) updateData.ico = body.ico;
if (body.dic !== undefined) updateData.dic = body.dic;
if (body.contact_person !== undefined)
updateData.contact_person = body.contact_person;
if (body.email !== undefined) updateData.email = body.email;
if (body.phone !== undefined) updateData.phone = body.phone;
if (body.address !== undefined) updateData.address = body.address;
if (body.notes !== undefined) updateData.notes = body.notes;
if (body.street !== undefined) updateData.street = body.street;
if (body.city !== undefined) updateData.city = body.city;
if (body.postal_code !== undefined)
updateData.postal_code = body.postal_code;
if (body.country !== undefined) updateData.country = body.country;
if (body.custom_fields !== undefined)
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_suppliers.update({
@@ -365,13 +381,13 @@ export default async function warehouseRoutes(
oldValues: {
name: existing.name,
ico: existing.ico,
contact_person: existing.contact_person,
city: existing.city,
is_active: existing.is_active,
},
newValues: {
name: updated.name,
ico: updated.ico,
contact_person: updated.contact_person,
city: updated.city,
is_active: updated.is_active,
},
});

View File

@@ -6,6 +6,7 @@ import {
positiveNumberFromForm,
nullableIntIdFromForm,
booleanFromForm,
DocumentSectionSchema,
} from "./common";
const InvoiceItemSchema = z.object({
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(),
language: z.string().max(20).optional().default("cs"),
notes: z.string().max(8000).nullish(),
// `notes` was dropped (printed-notes block removed — free-form PDF content
// lives in the sections); legacy clients still sending it are silently
// stripped by z.object. internal_notes is never printed.
internal_notes: z.string().max(8000).nullish(),
items: z.array(InvoiceItemSchema).optional(),
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(),
});
export const UpdateInvoiceSchema = z.object({
@@ -61,10 +67,10 @@ export const UpdateInvoiceSchema = z.object({
issue_date: z.union([z.string().max(255), z.null()]).optional(),
due_date: z.union([z.string().max(255), z.null()]).optional(),
tax_date: z.union([z.string().max(255), z.null()]).optional(),
notes: z.string().max(8000).nullish(),
internal_notes: z.string().max(8000).nullish(),
paid_date: z.union([z.string().max(255), z.null()]).optional(),
items: z.array(InvoiceItemSchema).optional(),
sections: z.array(DocumentSectionSchema).optional(),
});
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -8,7 +8,6 @@ import {
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
emailOrEmpty,
} from "./common";
// === Categories ===
@@ -26,26 +25,30 @@ export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
// === 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({
name: z.string().min(1, "Název je povinný").max(255),
ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(),
phone: z.string().max(50).nullish(),
address: z.string().max(5000).nullish(),
notes: z.string().max(5000).nullish(),
street: z.string().max(255).nullish(),
city: z.string().max(255).nullish(),
postal_code: z.string().max(20).nullish(),
country: z.string().max(100).nullish(),
custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional().default(true),
});
export const UpdateSupplierSchema = z.object({
name: z.string().min(1, "Název je povinný").max(255).optional(),
ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(),
phone: z.string().max(50).nullish(),
address: z.string().max(5000).nullish(),
notes: z.string().max(5000).nullish(),
street: z.string().max(255).nullish(),
city: z.string().max(255).nullish(),
postal_code: z.string().max(20).nullish(),
country: z.string().max(100).nullish(),
custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional(),
});
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 planRoutes from "./routes/admin/plan";
import aiRoutes from "./routes/admin/ai";
import aresRoutes from "./routes/admin/ares";
const app = Fastify({
logger: {
@@ -162,6 +163,7 @@ async function start() {
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
await app.register(planRoutes, { prefix: "/api/admin/plan" });
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
await app.register(aresRoutes, { prefix: "/api/admin/ares" });
// --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) {
@@ -204,12 +206,35 @@ async function start() {
root: path.join(__dirname, "..", "dist-client"),
prefix: "/",
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) => {
if (request.url.startsWith("/api/")) {
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");
});
}

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;
}
interface InvoiceSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
}
/**
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
* loosely typed because the payload arrives validated-but-coerced from the Zod
@@ -62,10 +68,10 @@ export interface InvoiceInput {
issued_by?: string | null;
billing_text?: string | null;
language?: string;
notes?: string | null;
internal_notes?: string | null;
paid_date?: string | null;
items?: InvoiceItemInput[];
sections?: InvoiceSectionInput[];
[key: string]: unknown;
}
@@ -469,14 +475,17 @@ export async function getInvoice(id: number) {
include: {
customers: true,
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 } },
},
});
if (!invoice) return null;
const { invoice_items, ...rest } = invoice;
const { invoice_items, invoice_sections, ...rest } = invoice;
return {
...rest,
items: invoice_items,
sections: invoice_sections,
customer: invoice.customers,
customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null,
@@ -499,48 +508,69 @@ export async function createInvoice(body: InvoiceInput) {
explicit ??
(status === "draft" ? null : (await generateInvoiceNumber()).number);
const invoice = await prisma.invoices.create({
data: {
invoice_number: invoiceNumber,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null,
constant_symbol: body.constant_symbol
? String(body.constant_symbol)
: null,
bank_name: body.bank_name ? String(body.bank_name) : null,
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
bank_account: body.bank_account ? String(body.bank_account) : null,
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
billing_text: body.billing_text ? String(body.billing_text) : null,
language: body.language ? String(body.language) : "cs",
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({
data: body.items.map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
// 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: {
invoice_number: invoiceNumber,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method
? String(body.payment_method)
: null,
constant_symbol: body.constant_symbol
? String(body.constant_symbol)
: null,
bank_name: body.bank_name ? String(body.bank_name) : null,
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
bank_account: body.bank_account ? String(body.bank_account) : null,
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
billing_text: body.billing_text ? String(body.billing_text) : null,
language: body.language ? String(body.language) : "cs",
internal_notes: body.internal_notes
? String(body.internal_notes)
: null,
},
});
}
if (Array.isArray(body.items)) {
await tx.invoice_items.createMany({
data: body.items.map((item, i) => ({
invoice_id: created.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
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;
}
@@ -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;
}
// Notes editable in draft/issued/overdue
// Internal notes editable in draft/issued/overdue (never printed)
if (editable) {
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes
? String(body.internal_notes)
@@ -644,22 +672,39 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
await prisma.invoices.update({ where: { id }, data });
}
// Allow items update while editable (draft/issued/overdue).
if (editable && Array.isArray(body.items)) {
// Allow items/sections full-replace while editable (draft/issued/overdue)
// 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 tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: body.items!.map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
if (replaceItems) {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: body.items!.map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
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,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
street: true,
city: true,
postal_code: true,
country: true,
},
},
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({
where: { order_id: id },
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.
*/
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(
@@ -95,16 +104,16 @@ export async function htmlToPdf(
format: "A4",
printBackground: true,
margin: {
top: "10mm",
top: options.headerTemplate ? "32mm" : "10mm",
bottom: options.footerTemplate ? "18mm" : "10mm",
left: "10mm",
right: "10mm",
},
...(options.footerTemplate
...(options.footerTemplate || options.headerTemplate
? {
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: options.footerTemplate,
headerTemplate: options.headerTemplate || "<span></span>",
footerTemplate: options.footerTemplate || "<span></span>",
}
: {}),
timeout: 15_000,

View File

@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
.replace(/"/g, "&quot;");
}
/**
* Company logo from company_settings.logo_data as a data: URI (mime sniffed
* from magic bytes). Data URIs are the ONLY image source Chromium loads in
* header/footer templates; the body templates reuse the same URI.
*/
export function logoDataUriFromSettings(
settings: Record<string, unknown> | null,
): string {
if (!settings?.logo_data) return "";
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
return `data:${escapeHtml(mime)};base64,${buf.toString("base64")}`;
}
/**
* Unified repeating per-page header for the red-accent PDF family (invoices +
* issued orders), rendered by Puppeteer in the top page margin: company logo
* left (max 22mm tall — same size as the documents' original body header),
* red bold heading right, 2pt #de3a3a rule underneath.
*
* Contract shared with the footers: inline styles only, explicit font-size
* (Chromium defaults template text to 0), images as data: URIs only, padding
* matched to the documents' 12mm @page side margins. The @page top margin
* must be 32mm to make room (htmlToPdf grows it when headerTemplate is set,
* but Chromium lays out by the CSS @page margins — keep them in sync).
*/
export function buildPdfHeaderTemplate(
logoDataUri: string,
heading: string,
): string {
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
</div>
</div>`;
}
/**
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,