Compare commits
6 Commits
f268907848
...
v2.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575c07aac8 | ||
|
|
4bf245d35c | ||
|
|
79d026e756 | ||
|
|
40a859f5e1 | ||
|
|
ffae1a4e74 | ||
|
|
5c9ebddf50 |
761
CLAUDE.md
761
CLAUDE.md
@@ -1,25 +1,26 @@
|
||||
# CLAUDE.md — boha-app-ts
|
||||
|
||||
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
||||
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
|
||||
Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
||||
| HTTP Framework | Fastify 5.8.2 |
|
||||
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
|
||||
| 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:00–02:00
|
||||
Prague).
|
||||
3. **Two deliberate write regimes — don't "fix" either:** the plan module
|
||||
does date-only math in UTC; attendance/leave writes `@db.Date` at **local
|
||||
noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from
|
||||
`localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) —
|
||||
**never** `new Date().toISOString().split("T")[0]` (UTC date, a day early
|
||||
in the Prague evening).
|
||||
|
||||
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
|
||||
- All API responses with Date fields will contain local time strings.
|
||||
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
|
||||
- **Never assume UTC** when working with Date objects in this codebase.
|
||||
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
|
||||
- The override exists for PHP migration compatibility and Czech date display.
|
||||
---
|
||||
|
||||
## Document Modules (offers · orders · issued orders · invoices)
|
||||
|
||||
**Business rules (user/accountant decisions — do not revert):**
|
||||
|
||||
- **VAT exists ONLY on invoices and received invoices.** Offers, order
|
||||
confirmations and issued orders are NOT tax documents: net prices only, one
|
||||
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
|
||||
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
|
||||
from an order prefill the company default VAT rate.
|
||||
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
|
||||
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
|
||||
offers take customers. Issued orders share the `orders._` permission family
|
||||
(deliberate).
|
||||
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
|
||||
design; invoices + issued orders share the red-accent (#de3a3a) family.
|
||||
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
|
||||
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
|
||||
orders deliberately have NO scope-template picker, NO item templates, NO
|
||||
duplicate action.
|
||||
|
||||
**Platform conventions:**
|
||||
|
||||
- **Deferred numbering:** drafts carry a NULL document number
|
||||
(nullable-unique column); the sequence number is consumed in-transaction on
|
||||
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
|
||||
released-if-highest on delete. Never hardcode numbering; previews use the
|
||||
collision-advancing helpers.
|
||||
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
|
||||
responses return `valid_transitions` and the UI renders transition buttons
|
||||
from it. Field edits outside the editable states (offers: draft/active;
|
||||
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
|
||||
payloads still pass.
|
||||
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
|
||||
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
|
||||
enriches `locked_by {user_id, username, full_name}` only when fresh and
|
||||
held by another user; client side is the shared `useDocumentLock` hook +
|
||||
`LockBanner`.
|
||||
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
|
||||
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
|
||||
so the UI hides PDF buttons on drafts. Numbered-document archives write to
|
||||
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||
suffixes. The issued-order PDF gets language from the document's `language`
|
||||
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
|
||||
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
|
||||
- **Shared modules — extend, never fork local copies:**
|
||||
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
|
||||
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
|
||||
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
|
||||
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
|
||||
diacritics in headers make Node throw 500s).
|
||||
|
||||
---
|
||||
|
||||
## TOTP / 2FA
|
||||
|
||||
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
|
||||
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
|
||||
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
|
||||
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
|
||||
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
|
||||
Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
|
||||
hex formats both supported); encrypted backup codes with their own
|
||||
`/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
|
||||
enrollment. Login flow: password → (if enrolled) single-use 5-min
|
||||
`loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
|
||||
QR is generated locally with the `qrcode` package (never an external QR URL —
|
||||
CSP blocks it and it would leak the secret).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
|
||||
|
||||
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy` → `DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`.
|
||||
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
|
||||
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
|
||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
||||
|
||||
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
|
||||
- The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
|
||||
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
|
||||
unless `DATABASE_URL` names a `*test*` database.
|
||||
- **After any schema change, apply migrations to the test DB too** or the
|
||||
suite fails: set `DATABASE_URL` to the app_test URL and run
|
||||
`npx prisma migrate deploy`.
|
||||
- To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
|
||||
with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
|
||||
`npx tsx prisma/seed.ts`.
|
||||
- **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
|
||||
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
|
||||
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
|
||||
sequence deadlocks. Tests are type-checked by `tsc -b`.
|
||||
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
|
||||
cleanup; FK-safe deletion order. New features get tests in
|
||||
`src/__tests__/<feature>.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
||||
- Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
|
||||
toasts via `useAlert()`.
|
||||
- **React Query for all fetching** (query options live in
|
||||
`src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
|
||||
effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
|
||||
server message through). `apiFetch` refreshes tokens proactively.
|
||||
- **Rules of Hooks:** every hook runs BEFORE any early return — the
|
||||
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
|
||||
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
|
||||
— prefix matching covers sub-queries), and a mutation invalidates every
|
||||
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
|
||||
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
|
||||
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
|
||||
still must invalidate their domains.
|
||||
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
|
||||
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
|
||||
DB. Don't duplicate label maps.
|
||||
|
||||
### Query Invalidation Convention
|
||||
### Styling (MUI v7 — no custom .css, no Tailwind)
|
||||
|
||||
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
|
||||
|
||||
- `["users"]` over `["users", "list"]`
|
||||
- `["trips"]` over `["trips", "vehicles"]`
|
||||
|
||||
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
|
||||
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
|
||||
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
|
||||
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
|
||||
|
||||
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
|
||||
|
||||
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
|
||||
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
|
||||
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
|
||||
- Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
|
||||
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/` —
|
||||
reach for raw `@mui/material` only for one-off layout/infra.
|
||||
- Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
|
||||
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
|
||||
one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
|
||||
- Don't regress: status row tints are channel-alpha washes (never `.light`
|
||||
fills — invisible text in dark mode); icon badges are solid `X.main` +
|
||||
white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
|
||||
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
|
||||
`useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
|
||||
fade; ConfirmDialogs stay open with `loading` during their request.
|
||||
- When refactoring pages, **preserve ALL data logic verbatim** (hooks,
|
||||
mutations, invalidate arrays, validation, permissions).
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
||||
## AI Assistant — "Odin"
|
||||
|
||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
||||
|
||||
**Where styling lives**
|
||||
|
||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
||||
|
||||
**Building / editing pages**
|
||||
|
||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
||||
|
||||
**Conventions learned — don't regress**
|
||||
|
||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
||||
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
|
||||
Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
|
||||
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
|
||||
(text-only messages get a canned reply, no API call). Backend
|
||||
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger
|
||||
in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
|
||||
PDF → `extract-invoices` (structured output) → review cards → existing
|
||||
`POST /received-invoices`. **`received_invoices.amount` is GROSS
|
||||
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
|
||||
notes live in `docs/superpowers/specs/`.
|
||||
|
||||
---
|
||||
|
||||
## AI Assistant — "Odin" (shipped v2.1.5)
|
||||
## Database & Migrations
|
||||
|
||||
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase.
|
||||
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
|
||||
soft-delete via `is_deleted` where present; `number_sequences` owns all
|
||||
document numbering.
|
||||
|
||||
- **SDK / model:** `@anthropic-ai/sdk` → `claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`).
|
||||
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
|
||||
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
|
||||
- **Frontend:** `src/admin/pages/Odin.tsx` → `src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces`→`Newsreader` font added to `index.html`.
|
||||
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
|
||||
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
|
||||
**Golden rules:**
|
||||
|
||||
---
|
||||
- **NEVER `prisma db push`** — it bypasses migration history and causes drift.
|
||||
- **Every DB change is a tracked migration** — schema, permission/seed rows,
|
||||
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
|
||||
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
|
||||
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
|
||||
`APP_ENV=production`) — prod baselines belong in migrations.
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
|
||||
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
|
||||
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
|
||||
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations (Critical Workflow)
|
||||
|
||||
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
|
||||
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
|
||||
|
||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
||||
|
||||
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
|
||||
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
|
||||
- Lookup data, default settings, or any other row-level baseline that production needs
|
||||
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
|
||||
|
||||
**What is NOT acceptable:**
|
||||
|
||||
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
|
||||
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
|
||||
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
|
||||
|
||||
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
|
||||
|
||||
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
|
||||
|
||||
### Making schema changes
|
||||
|
||||
```
|
||||
1. Edit prisma/schema.prisma
|
||||
2. npx prisma migrate dev --name descriptive_name
|
||||
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
|
||||
3. npx prisma generate
|
||||
4. Commit BOTH schema.prisma AND the new migration folder
|
||||
git add prisma/schema.prisma prisma/migrations/
|
||||
```
|
||||
|
||||
### Verifying before production deploy
|
||||
**Making a schema change (this shell is non-interactive — `prisma migrate
|
||||
dev` will refuse to run; use this recipe):**
|
||||
|
||||
```bash
|
||||
# Preview what SQL will run on production (no changes applied)
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://user:pass@prod:3306/app" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# Empty output = no diff. If it shows SQL, review it before deploying.
|
||||
# 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
|
||||
# 2. Generate the migration SQL into a new folder:
|
||||
mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
|
||||
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||
> prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
|
||||
# 3. Review the SQL, then apply + regenerate:
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
# 4. Apply to the test DB as well (suite breaks otherwise):
|
||||
# DATABASE_URL=<app_test url> npx prisma migrate deploy
|
||||
# 5. Commit schema.prisma AND the migration folder together.
|
||||
```
|
||||
|
||||
### Deploying migrations to production
|
||||
|
||||
The release process runs `prisma migrate deploy` on production.
|
||||
This applies only pending migrations — safe, idempotent.
|
||||
|
||||
### If migrations get out of sync (drift)
|
||||
|
||||
```bash
|
||||
# Diff production DB against local schema to find drift
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://prod" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
|
||||
# If drift includes DROPs: investigate before touching production
|
||||
```
|
||||
|
||||
### Baselinining a database that has no migrations
|
||||
|
||||
If production was synced with `db push` and has no `_prisma_migrations` table:
|
||||
|
||||
```bash
|
||||
# 1. Create initial migration locally
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 2. Copy to production and mark as applied (no SQL runs)
|
||||
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
|
||||
ssh user@prod
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate resolve --applied <migration_name>
|
||||
```
|
||||
Deployment, drift checks, hotfix-migration shipping and baselining:
|
||||
see **`docs/release.md`**.
|
||||
|
||||
---
|
||||
|
||||
## Conventions (enforced — verified by the 2026-06-06 audit)
|
||||
|
||||
These are the unified rules across the codebase. Follow them for new code; the
|
||||
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
|
||||
## Enforced Conventions (single source — merged 2026-06 audits)
|
||||
|
||||
**Routes**
|
||||
|
||||
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
|
||||
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
|
||||
- **Bodies:** validate every body with `parseBody(Schema, request.body)` → `if ("error" in body) return error(reply, body.error, 400)`.
|
||||
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
|
||||
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
|
||||
|
||||
**Services**
|
||||
|
||||
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
|
||||
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
|
||||
- `success()`/`error()`/paginated helpers only; `parseId` for numeric params
|
||||
(`if (id === null) return;`); `parseBody` for every body; permission guards
|
||||
on reads AND writes; `logAudit` with old/new values on every mutation.
|
||||
- Role-management writes are admin-only and re-checked (no creating or
|
||||
rewriting the `admin` role).
|
||||
|
||||
**Schemas (Zod 4)**
|
||||
|
||||
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
|
||||
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
|
||||
- User-facing messages in **Czech**; identifiers/keys in English.
|
||||
- `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
|
||||
- **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
|
||||
form-coerced fields: `numberFromForm`, `numberInRange`,
|
||||
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
|
||||
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
|
||||
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
|
||||
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
|
||||
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
|
||||
the single biggest historical bug class). FK ids → intIdFromForm;
|
||||
quantities/prices → nonNegative/positive; bounded → numberInRange.
|
||||
- The date/time helpers are deliberately **lenient** (strip trailing time
|
||||
components) because edit forms re-submit `toJSON`-serialized values.
|
||||
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
|
||||
- Align `max()` caps with the DB column widths (an over-cap string 500s at
|
||||
Prisma instead of 400ing at Zod). Update schemas derive via
|
||||
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
|
||||
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
|
||||
still injects field defaults into partial payloads).
|
||||
- User-facing messages in **Czech**; identifiers/tokens in English.
|
||||
|
||||
**Frontend**
|
||||
**Determinism & concurrency**
|
||||
|
||||
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
|
||||
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
|
||||
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
|
||||
- Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
|
||||
columns make single-key sorts non-deterministic).
|
||||
- Read-modify-write of shared rows (stock, balances, sequences) runs inside
|
||||
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
|
||||
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
|
||||
writes (header + items + sections) belong in ONE transaction.
|
||||
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
|
||||
the checker) and catch `P2002` → 409 as backstop.
|
||||
|
||||
**Dates (two deliberate regimes — don't "fix" either)**
|
||||
**Errors**
|
||||
|
||||
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
|
||||
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
|
||||
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
|
||||
|
||||
## Conventions (enforced — added by the 2026-06-09 full audit)
|
||||
|
||||
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
|
||||
|
||||
**Zod validation — shared coercion helpers are MANDATORY**
|
||||
|
||||
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
|
||||
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0–100`, month `1–12`) → `numberInRange`.
|
||||
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
|
||||
- An optional email a form may submit as `""` → **`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
|
||||
|
||||
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
|
||||
|
||||
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
|
||||
|
||||
**Concurrency — locking discipline for stock / balance / sequence mutations**
|
||||
|
||||
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
|
||||
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
|
||||
|
||||
**Permissions — guard reads, not just writes**
|
||||
|
||||
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
|
||||
|
||||
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
|
||||
|
||||
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error** — `npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
|
||||
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
|
||||
|
||||
**Safety**
|
||||
|
||||
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
|
||||
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
|
||||
- Never silently swallow — every catch logs (`console.*` in services; there
|
||||
is no request-scoped logger there). The only allowed silent catch is an
|
||||
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
|
||||
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Gotchas
|
||||
## Known Gotchas
|
||||
|
||||
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
|
||||
|
||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
||||
|
||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
||||
|
||||
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
|
||||
|
||||
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
|
||||
|
||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
||||
|
||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
||||
|
||||
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
|
||||
|
||||
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
||||
|
||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
||||
|
||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
||||
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
|
||||
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
|
||||
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
|
||||
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
|
||||
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
|
||||
pass unsanitized user data into Puppeteer templates; string-built HTML
|
||||
(print views, PDF templates) must `escapeHtml` every interpolation.
|
||||
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
|
||||
with logged errors, and tests stub NAS reads.
|
||||
4. **Prisma client**: regenerate after every schema change; it is not
|
||||
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
|
||||
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
|
||||
6. **Czech locale hardcoded** in messages/month names — intentional.
|
||||
7. **Cross-login caches:** React Query keys are user-agnostic; the query
|
||||
cache is cleared on login/logout in AuthContext — keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
## Release
|
||||
|
||||
1. Bump version in `package.json`
|
||||
2. `npm run build`
|
||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma 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
96
docs/release.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Release & deployment runbook — boha-app-ts
|
||||
|
||||
Referenced from CLAUDE.md. **Never deploy to production or restart services
|
||||
without explicit user confirmation.**
|
||||
|
||||
## Standard release
|
||||
|
||||
1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`,
|
||||
`npm run lint` (0 errors).
|
||||
2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number).
|
||||
3. `npm run build`
|
||||
4. Commit and tag: `git tag -a vX.Y.Z`
|
||||
5. Push to Gitea: `git push origin master && git push origin vX.Y.Z`
|
||||
6. Create tarball:
|
||||
|
||||
```bash
|
||||
tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts
|
||||
```
|
||||
|
||||
⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||
without it, `prisma generate`/`migrate deploy` on prod have no datasource.
|
||||
|
||||
7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`):
|
||||
|
||||
```bash
|
||||
scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts
|
||||
rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
|
||||
tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
|
||||
npm install --omit=dev
|
||||
npx prisma generate # MANDATORY — see below
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
**`npx prisma generate` is mandatory.** `npm install` skips client
|
||||
regeneration when dependencies didn't change, leaving a stale client that
|
||||
still selects dropped/renamed columns → P2022 500s in prod (this caused
|
||||
the v2.4.0 incident: every issued-orders query failed until generate ran).
|
||||
|
||||
8. Verify: `pm2 list` (status online, correct version, restart counter not
|
||||
climbing), `npx prisma migrate status` ("up to date"),
|
||||
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200,
|
||||
and grep logs for errors from the CURRENT pid only (the out log keeps old
|
||||
errors from prior pids).
|
||||
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||
|
||||
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||
constraint names the migration DROPs, check no data violates new
|
||||
UNIQUE/RESTRICT constraints — and confirm with the user before the
|
||||
irreversible steps. The prod DB is backed up by a cron job (no manual
|
||||
mysqldump needed).
|
||||
|
||||
## Hotfixing a migration added after the tarball was built
|
||||
|
||||
A migration folder created after the release tarball shipped is not on prod,
|
||||
so `prisma migrate deploy` reports "No pending migrations". Ship just the
|
||||
migration folder (still tracked in git — this is NOT raw SQL on prod):
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/<name>_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/<name>_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
tar -xzf /tmp/<name>_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
## Drift check / preview before deploy
|
||||
|
||||
```bash
|
||||
# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema
|
||||
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
|
||||
```
|
||||
|
||||
Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another
|
||||
DB need its URL in a config/env override.) If drift includes DROPs,
|
||||
investigate before touching production.
|
||||
|
||||
## Baselining a database that has no migrations
|
||||
|
||||
If a database was synced without migration history (no `_prisma_migrations`
|
||||
table): create the initial migration locally, copy `prisma/migrations` to the
|
||||
server, then mark it applied without running SQL:
|
||||
|
||||
```bash
|
||||
npx prisma migrate resolve --applied <migration_name>
|
||||
```
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.4",
|
||||
"version": "2.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.4",
|
||||
"version": "2.4.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.4",
|
||||
"version": "2.4.5",
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
@@ -795,7 +808,7 @@ model sklad_suppliers {
|
||||
name String @db.VarChar(255)
|
||||
ico String? @db.VarChar(20)
|
||||
dic String? @db.VarChar(20)
|
||||
contact_person String? @db.VarChar(255)
|
||||
contact_person String? @db.VarChar(255)
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
address String? @db.Text
|
||||
@@ -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")
|
||||
|
||||
@@ -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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -1148,7 +1182,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 +1321,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 +1632,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">
|
||||
@@ -1659,43 +1735,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 +1943,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 +2301,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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> ${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(),
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
escapeHtml,
|
||||
cleanQuillHtml,
|
||||
buildQuillIndentCss,
|
||||
logoDataUriFromSettings,
|
||||
buildPdfHeaderTemplate,
|
||||
} from "../../utils/pdf-shared";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -226,13 +228,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 +266,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 +361,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 +636,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 +814,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 +831,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`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -310,6 +312,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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,47 @@ export function escapeHtml(str: string | null | undefined): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
Reference in New Issue
Block a user