Compare commits
329 Commits
v1.9.2
...
4258699b73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4258699b73 | ||
|
|
bfd2c59ad3 | ||
|
|
0b69dbfde0 | ||
|
|
db7a5c3d15 | ||
|
|
f1ce76d21d | ||
|
|
575c07aac8 | ||
|
|
4bf245d35c | ||
|
|
79d026e756 | ||
|
|
40a859f5e1 | ||
|
|
ffae1a4e74 | ||
|
|
5c9ebddf50 | ||
|
|
f268907848 | ||
|
|
dd8c699420 | ||
|
|
268a947c85 | ||
|
|
75dc97516e | ||
|
|
d1533ffc4d | ||
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 | ||
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 | ||
|
|
4e534471d9 | ||
|
|
975a555af5 | ||
|
|
6a22195c7d | ||
|
|
74ce24e3fa | ||
|
|
8ee5a443ef | ||
|
|
b3e2abf9b2 | ||
|
|
1826fc7976 | ||
|
|
d08e55a41a | ||
|
|
97101c4db7 | ||
|
|
1e4dd1fbcc | ||
|
|
5462fcf944 | ||
|
|
78cd7cf2d8 | ||
|
|
cb82349c86 | ||
|
|
d445384408 | ||
|
|
3268cf2100 | ||
|
|
841ab676ec | ||
|
|
313d9218c1 | ||
|
|
c270cb22e9 | ||
|
|
113edd9a0d | ||
|
|
753c16423c | ||
|
|
11b71501ee | ||
|
|
5c03570f07 | ||
|
|
ed9ea52a44 | ||
|
|
9e9aacdf0b | ||
|
|
52ff0a6ffa | ||
|
|
a15fa68c3b | ||
|
|
e86c2e9a80 | ||
|
|
ce636215dc | ||
|
|
473f7c4a8c | ||
|
|
28186397a0 | ||
|
|
a3948c7da1 | ||
|
|
53c8359acc | ||
|
|
c2d7a96718 | ||
|
|
61673247bf | ||
|
|
9fe5113c5b | ||
|
|
e868ecf769 | ||
|
|
20177e8f87 | ||
|
|
39565767ef | ||
|
|
c4668357aa | ||
|
|
b4f1b6ce2b | ||
|
|
66235ff567 | ||
|
|
d341db2e51 | ||
|
|
430ec3e76b | ||
|
|
f295af4a14 | ||
|
|
db11999c85 | ||
|
|
afef0e6759 | ||
|
|
62f50d31af | ||
|
|
3d3df6db85 | ||
|
|
162f9b9fdf | ||
|
|
3b48bd0523 | ||
|
|
2b7b480144 | ||
|
|
e26fce092a | ||
|
|
d7e0ba933d | ||
|
|
8b90cfed1f | ||
|
|
8476ffebd6 | ||
|
|
63ccf8fda7 | ||
|
|
9f4b8a897a | ||
|
|
2a3df15565 | ||
|
|
b1f48df30c | ||
|
|
f00fcf95fd | ||
|
|
4093664936 | ||
|
|
ecb83d4654 | ||
|
|
a06ea2e1cd | ||
|
|
15a2da38cc | ||
|
|
4072197f4a | ||
|
|
1914fddb4d | ||
|
|
63192dc781 | ||
|
|
6147131aad | ||
|
|
b4f859ea6e | ||
|
|
519edce373 | ||
|
|
c454d1a3fc | ||
|
|
b1f21a1f48 | ||
|
|
b6cd5046d5 | ||
|
|
25c8c39ba6 | ||
|
|
4b2770a000 | ||
|
|
434a12b4a8 | ||
|
|
28fb399217 | ||
|
|
c0ff7b4549 | ||
|
|
1e21c12f33 | ||
|
|
ab12f9d791 | ||
|
|
cf442b9a99 | ||
|
|
2e7caea5a6 | ||
|
|
702a4984ee | ||
|
|
769767c33d | ||
|
|
f18e2e0f2c | ||
|
|
d62abe81fd | ||
|
|
ac7c220bb1 | ||
|
|
b7ebf04001 | ||
|
|
441f46a471 | ||
|
|
f011a67ff3 | ||
|
|
92594f89c3 | ||
|
|
6664e3bc4d | ||
|
|
c5cfd855ba | ||
|
|
2565e0de21 | ||
|
|
817f0b2f00 | ||
|
|
e4ec08a5ef | ||
|
|
049ee492c0 | ||
|
|
f4bc038f8b | ||
|
|
58e5fd2b1d | ||
|
|
0226cdd52b | ||
|
|
b665ea1d9b | ||
|
|
ec74ded953 | ||
|
|
bcf63d00d1 | ||
|
|
096784768f | ||
|
|
3623b441ce | ||
|
|
4bf7bf4ed6 | ||
|
|
92252382ac | ||
|
|
8bed920de1 | ||
|
|
9d2992b722 | ||
|
|
1ddc0feccd | ||
|
|
cce2c9cfaa | ||
|
|
2610301258 | ||
|
|
13d1fc2be3 | ||
|
|
95ca258718 | ||
|
|
6db87bf4ae | ||
|
|
c6a146d57a | ||
|
|
1a262508d4 | ||
|
|
23ac924472 | ||
|
|
9370423fb0 | ||
|
|
a146fc26a3 | ||
|
|
72888bf9cd | ||
|
|
80dc8a5c69 | ||
|
|
2cfa28dc47 | ||
|
|
628cd54a81 | ||
|
|
9ae69e09a3 | ||
|
|
df83eb2091 | ||
|
|
9c862034ca | ||
|
|
67d62a6df0 | ||
|
|
512f1fd92b | ||
|
|
941caf9d85 | ||
|
|
b00d18d0b3 | ||
|
|
decadd895e | ||
|
|
54f3c414f5 | ||
|
|
ca092c6166 | ||
|
|
c7f9d9aa36 | ||
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 |
@@ -1,14 +1,23 @@
|
||||
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
||||
let data = '';
|
||||
process.stdin.on('data', chunk => data += chunk);
|
||||
process.stdin.on('end', () => {
|
||||
//
|
||||
// Hook exit-code contract (Claude Code PreToolUse):
|
||||
// exit 0 = allow (stdout JSON is only parsed on exit 0)
|
||||
// exit 2 = BLOCK — the reason must be written to STDERR
|
||||
// any other exit code = non-blocking error (the tool call proceeds)
|
||||
// So to actually block, we must print to stderr and exit 2.
|
||||
let data = "";
|
||||
process.stdin.on("data", (chunk) => (data += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const input = JSON.parse(data);
|
||||
const filePath = input.tool_input?.file_path || '';
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
if (basename === '.env') {
|
||||
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
|
||||
process.exit(1);
|
||||
const filePath = input.tool_input?.file_path || "";
|
||||
const basename = filePath.split("/").pop().split("\\").pop();
|
||||
if (basename === ".env") {
|
||||
console.error("Direct .env edits are blocked. Use .env.example instead.");
|
||||
process.exit(2);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2
|
||||
// here would block EVERY Edit/Write if the hook payload format changed.
|
||||
}
|
||||
});
|
||||
|
||||
63
.env.example
63
.env.example
@@ -1,32 +1,63 @@
|
||||
# Database
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
|
||||
# Required vars throw at boot if missing; optional vars show their defaults.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Required ──────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/app
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local
|
||||
|
||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
PORT=3001 # production port (dev is hardcoded to 3050)
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local # local | production — controls CSP/CORS/HSTS
|
||||
APP_URL= # base URL used in email links
|
||||
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
|
||||
|
||||
# ── Tokens (seconds) ──────────────────────────────────────────────────────
|
||||
ACCESS_TOKEN_EXPIRY=900
|
||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||
|
||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
|
||||
# TOTP_ALGORITHM=SHA1
|
||||
# TOTP_DIGITS=6
|
||||
# TOTP_PERIOD=30
|
||||
# LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
# File storage
|
||||
# ── Rate limiting (optional) ──────────────────────────────────────────────
|
||||
# RATE_LIMIT_MAX=300
|
||||
# RATE_LIMIT_WINDOW=1 minute
|
||||
# RATE_LIMIT_LOGIN=20
|
||||
# RATE_LIMIT_TOTP=5
|
||||
# RATE_LIMIT_REFRESH=10
|
||||
|
||||
# ── File storage (NAS network shares) ─────────────────────────────────────
|
||||
NAS_PATH=Z:/02_PROJEKTY
|
||||
MAX_UPLOAD_SIZE=52428800
|
||||
# NAS_INVOICES= # invoice PDFs — {base}/Vydané|Přijaté/YYYY/MM/ (falls back to NAS_FINANCIALS_PATH if unset)
|
||||
# NAS_ORDERS= # issued-order PDF archival — {base}/Vydané/YYYY/MM/ (off until set)
|
||||
# NAS_OFFERS_PATH=
|
||||
MAX_UPLOAD_SIZE=52428800 # 50 MB
|
||||
|
||||
# Email
|
||||
# ── Email ─────────────────────────────────────────────────────────────────
|
||||
CONTACT_EMAIL_TO=
|
||||
CONTACT_EMAIL_FROM=
|
||||
SMTP_FROM=
|
||||
# SMTP_FROM_NAME=
|
||||
LEAVE_NOTIFY_EMAIL=
|
||||
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
|
||||
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
|
||||
# App URL (for email links)
|
||||
APP_URL=
|
||||
|
||||
# CORS (production only, comma-separated)
|
||||
# ── CORS (production only, comma-separated) ───────────────────────────────
|
||||
CORS_ORIGINS=
|
||||
|
||||
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,9 +7,19 @@ dist/
|
||||
dist-client/
|
||||
*.css.map
|
||||
|
||||
# Build / tooling artifacts
|
||||
*.tsbuildinfo
|
||||
*.bak
|
||||
|
||||
# Local scratch
|
||||
.playwright-mcp/
|
||||
|
||||
# Release archives
|
||||
*.tar.gz
|
||||
|
||||
# Claude worktrees
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Superpowers brainstorm mockups
|
||||
.superpowers/
|
||||
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
dist/
|
||||
dist-client/
|
||||
node_modules/
|
||||
prisma/migrations/
|
||||
src/admin/bones/
|
||||
*.tsbuildinfo
|
||||
package-lock.json
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80
|
||||
}
|
||||
671
CLAUDE.md
671
CLAUDE.md
@@ -1,24 +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 6.19.2 → MySQL |
|
||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 |
|
||||
| Testing | Vitest 4.1.0 + Supertest |
|
||||
| PDF | Puppeteer 24.x |
|
||||
| Email | nodemailer 8.x |
|
||||
| Cron | node-cron 4.x |
|
||||
| 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.)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,30 +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 frontend (57 .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
|
||||
│ ├── contexts/ # AuthContext, AlertContext
|
||||
│ ├── components/ # Layout, modals, tables, editors
|
||||
│ ├── 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
|
||||
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (auth, numbering)
|
||||
│ ├── 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 # 32 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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -57,421 +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)
|
||||
npm run test:watch # vitest watch
|
||||
|
||||
# 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
|
||||
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 .
|
||||
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 test database (`.env.test`).
|
||||
|
||||
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
||||
- **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.
|
||||
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
|
||||
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## Database Conventions
|
||||
## AI Assistant — "Odin"
|
||||
|
||||
- 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.
|
||||
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/`.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations (Critical Workflow)
|
||||
## Database & Migrations
|
||||
|
||||
**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.
|
||||
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
|
||||
soft-delete via `is_deleted` where present; `number_sequences` owns all
|
||||
document numbering.
|
||||
|
||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
||||
**Golden rules:**
|
||||
|
||||
- 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
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
- Never silently swallow — every catch logs (`console.*` in services; there
|
||||
is no request-scoped logger there). The only allowed silent catch is an
|
||||
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
|
||||
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Gotchas
|
||||
## Known Gotchas
|
||||
|
||||
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
|
||||
|
||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
||||
|
||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
||||
|
||||
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
|
||||
|
||||
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
|
||||
|
||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
||||
|
||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
||||
|
||||
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
|
||||
|
||||
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
||||
|
||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
||||
|
||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
||||
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
|
||||
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
|
||||
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
|
||||
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
|
||||
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
|
||||
pass unsanitized user data into Puppeteer templates; string-built HTML
|
||||
(print views, PDF templates) must `escapeHtml` every interpolation.
|
||||
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
|
||||
with logged errors, and tests stub NAS reads.
|
||||
4. **Prisma client**: regenerate after every schema change; it is not
|
||||
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
|
||||
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
|
||||
6. **Czech locale hardcoded** in messages/month names — intentional.
|
||||
7. **Cross-login caches:** React Query keys are user-agnostic; the query
|
||||
cache is cleared on login/logout in AuthContext — keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
## Release
|
||||
|
||||
1. Bump version in `package.json`
|
||||
2. `npm run build`
|
||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||
- Path: `/var/www/app-ts`
|
||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||
- Install dependencies: `npm install --omit=dev`
|
||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||
- Restart: `pm2 restart app-ts --update-env`
|
||||
|
||||
### Hotfixing a migration that was added after the tarball was built
|
||||
|
||||
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
|
||||
release tarball has already been built and shipped, that migration is
|
||||
NOT in the tarball and `prisma migrate deploy` on prod will report
|
||||
"No pending migrations". The release tarball re-build re-runs the whole
|
||||
release, but for a one-off hotfix you can ship just the new migration
|
||||
folder:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
The migration is still tracked in git and applied via `prisma migrate
|
||||
deploy` — the only thing the tarball is doing is delivering the
|
||||
`migration.sql` to prod, which is the same mechanism the main release
|
||||
uses. This is preferred over running raw SQL on prod (which the policy
|
||||
in "Database Migrations" forbids).
|
||||
|
||||
Do not push directly to production or restart services without confirmation.
|
||||
Process, deployment commands, hotfix-migration shipping and verification
|
||||
checklist: **`docs/release.md`**. Run the full test suite before tagging; the
|
||||
user picks version numbers. **Never push to production or restart services
|
||||
without explicit confirmation.**
|
||||
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# boha-app-ts
|
||||
|
||||
Internal business-management system for a Czech company (attendance, invoicing,
|
||||
leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite
|
||||
of the legacy PHP app. User-facing text is Czech by design.
|
||||
|
||||
> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA
|
||||
- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an
|
||||
SPA in `src/admin/`, served by the same Fastify process (Vite middleware in
|
||||
dev, static files in prod)
|
||||
- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the
|
||||
admins-only "Odin" assistant)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (LTS) and a MySQL database
|
||||
- Copy `.env.example` → `.env` and fill in the **required** vars (`DATABASE_URL`,
|
||||
`JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma generate
|
||||
npx prisma migrate dev # apply migrations to your dev DB
|
||||
npx prisma db seed # optional: dev-only sample data (NEVER on prod)
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm run dev:server # API (tsx watch) on http://127.0.0.1:3050
|
||||
npm run dev:client # Vite dev server (manage separately)
|
||||
```
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
npm run build # tsc server → dist/ + vite client → dist-client/
|
||||
npm start # node dist/server.js
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks
|
||||
```
|
||||
|
||||
## Quality gates
|
||||
|
||||
Before committing, all of these must pass:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`)
|
||||
npm run build
|
||||
npx vitest run
|
||||
npm run lint # ESLint (incl. react-hooks) — see eslint.config.js
|
||||
```
|
||||
|
||||
## Database migrations
|
||||
|
||||
Every schema/data change is a tracked Prisma migration — **never** `prisma db push`
|
||||
or raw SQL on production. Stop the dev server before running `prisma migrate dev`.
|
||||
See CLAUDE.md → _Database Migrations_ for the full workflow and the production
|
||||
deploy/hotfix process.
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
386
REVIEW.md
Normal file
386
REVIEW.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Codebase Audit — REVIEW.md
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Scope:** entire repository (tracked source/config files via `git ls-files`)
|
||||
**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction.
|
||||
|
||||
**Total files to review:** 277
|
||||
|
||||
---
|
||||
|
||||
|
||||
## .claude/hooks/
|
||||
|
||||
- [x] .claude/hooks/block-env.js
|
||||
- [x] .claude/hooks/format-on-save.js
|
||||
|
||||
## .claude/
|
||||
|
||||
- [x] .claude/settings.json
|
||||
- [x] .claude/settings.local.json
|
||||
|
||||
## ./
|
||||
|
||||
- [x] .env.example
|
||||
- [x] CLAUDE.md
|
||||
- [x] boneyard.config.json
|
||||
- [x] index.html
|
||||
- [x] package.json
|
||||
|
||||
## prisma/
|
||||
|
||||
- [x] prisma/schema.prisma
|
||||
- [x] prisma/seed.ts
|
||||
|
||||
## scripts/
|
||||
|
||||
- [x] scripts/migrate-received-invoices-to-nas.ts
|
||||
- [x] scripts/rotate-totp-key.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/App.tsx
|
||||
|
||||
## src/__tests__/
|
||||
|
||||
- [x] src/__tests__/ai.test.ts
|
||||
- [x] src/__tests__/auth.service.test.ts
|
||||
- [x] src/__tests__/auth.test.ts
|
||||
- [x] src/__tests__/customers.schema.test.ts
|
||||
- [x] src/__tests__/env.test.ts
|
||||
- [x] src/__tests__/exchange-rates.test.ts
|
||||
- [x] src/__tests__/helpers.ts
|
||||
- [x] src/__tests__/invoices.service.test.ts
|
||||
- [x] src/__tests__/manual-create.test.ts
|
||||
- [x] src/__tests__/nas-file-manager.test.ts
|
||||
- [x] src/__tests__/nas-project-folder.test.ts
|
||||
- [x] src/__tests__/numbering.test.ts
|
||||
- [x] src/__tests__/plan.test.ts
|
||||
- [x] src/__tests__/planAuditDescription.test.ts
|
||||
- [x] src/__tests__/planCategory.test.ts
|
||||
- [x] src/__tests__/received-invoices-vat.test.ts
|
||||
- [x] src/__tests__/schema-nan.test.ts
|
||||
- [x] src/__tests__/setup.ts
|
||||
- [x] src/__tests__/warehouse.test.ts
|
||||
|
||||
## src/admin/
|
||||
|
||||
- [x] src/admin/AdminApp.tsx
|
||||
- [x] src/admin/GlobalStyles.tsx
|
||||
|
||||
## src/admin/components/
|
||||
|
||||
- [x] src/admin/components/AlertContainer.tsx
|
||||
- [x] src/admin/components/AttendanceShiftTable.tsx
|
||||
- [x] src/admin/components/BulkAttendanceModal.tsx
|
||||
- [x] src/admin/components/BulkPlanModal.tsx
|
||||
- [x] src/admin/components/ErrorBoundary.tsx
|
||||
- [x] src/admin/components/Forbidden.tsx
|
||||
- [x] src/admin/components/OrderConfirmationModal.tsx
|
||||
- [x] src/admin/components/PlanCategoriesModal.tsx
|
||||
- [x] src/admin/components/PlanCellModal.tsx
|
||||
- [x] src/admin/components/PlanGrid.tsx
|
||||
- [x] src/admin/components/PlanRangeChips.tsx
|
||||
- [x] src/admin/components/ProjectFileManager.tsx
|
||||
- [x] src/admin/components/RichEditor.tsx
|
||||
- [x] src/admin/components/ShiftFormModal.tsx
|
||||
- [x] src/admin/components/ShortcutsHelp.tsx
|
||||
|
||||
## src/admin/components/dashboard/
|
||||
|
||||
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
|
||||
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
|
||||
- [x] src/admin/components/dashboard/DashKpiCards.tsx
|
||||
- [x] src/admin/components/dashboard/DashProfile.tsx
|
||||
- [x] src/admin/components/dashboard/DashQuickActions.tsx
|
||||
- [x] src/admin/components/dashboard/DashSessions.tsx
|
||||
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
|
||||
|
||||
## src/admin/components/odin/
|
||||
|
||||
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
|
||||
- [x] src/admin/components/odin/OdinChat.tsx
|
||||
- [x] src/admin/components/odin/OdinComposer.tsx
|
||||
- [x] src/admin/components/odin/OdinMark.tsx
|
||||
- [x] src/admin/components/odin/OdinSidebar.tsx
|
||||
- [x] src/admin/components/odin/OdinThread.tsx
|
||||
- [x] src/admin/components/odin/types.ts
|
||||
|
||||
## src/admin/components/warehouse/
|
||||
|
||||
- [x] src/admin/components/warehouse/ItemPicker.tsx
|
||||
- [x] src/admin/components/warehouse/ReservationPicker.tsx
|
||||
|
||||
## src/admin/context/
|
||||
|
||||
- [x] src/admin/context/AlertContext.tsx
|
||||
- [x] src/admin/context/AuthContext.tsx
|
||||
|
||||
## src/admin/hooks/
|
||||
|
||||
- [x] src/admin/hooks/useAttendanceAdmin.ts
|
||||
- [x] src/admin/hooks/useDebounce.ts
|
||||
- [x] src/admin/hooks/useModalLock.ts
|
||||
- [x] src/admin/hooks/usePaginatedQuery.ts
|
||||
- [x] src/admin/hooks/usePlanWork.ts
|
||||
- [x] src/admin/hooks/useReducedMotion.ts
|
||||
- [x] src/admin/hooks/useTableSort.ts
|
||||
|
||||
## src/admin/lib/
|
||||
|
||||
- [x] src/admin/lib/apiAdapter.ts
|
||||
- [x] src/admin/lib/entityTypeLabels.ts
|
||||
|
||||
## src/admin/lib/queries/
|
||||
|
||||
- [x] src/admin/lib/queries/ai.ts
|
||||
- [x] src/admin/lib/queries/attendance.ts
|
||||
- [x] src/admin/lib/queries/auditLog.ts
|
||||
- [x] src/admin/lib/queries/common.ts
|
||||
- [x] src/admin/lib/queries/dashboard.ts
|
||||
- [x] src/admin/lib/queries/invoices.ts
|
||||
- [x] src/admin/lib/queries/leave.ts
|
||||
- [x] src/admin/lib/queries/mutations.ts
|
||||
- [x] src/admin/lib/queries/offers.ts
|
||||
- [x] src/admin/lib/queries/orders.ts
|
||||
- [x] src/admin/lib/queries/plan.ts
|
||||
- [x] src/admin/lib/queries/projects.ts
|
||||
- [x] src/admin/lib/queries/settings.ts
|
||||
- [x] src/admin/lib/queries/trips.ts
|
||||
- [x] src/admin/lib/queries/users.ts
|
||||
- [x] src/admin/lib/queries/vehicles.ts
|
||||
- [x] src/admin/lib/queries/warehouse.ts
|
||||
|
||||
## src/admin/lib/
|
||||
|
||||
- [x] src/admin/lib/queryClient.ts
|
||||
|
||||
## src/admin/pages/
|
||||
|
||||
- [x] src/admin/pages/Attendance.tsx
|
||||
- [x] src/admin/pages/AttendanceAdmin.tsx
|
||||
- [x] src/admin/pages/AttendanceBalances.tsx
|
||||
- [x] src/admin/pages/AttendanceCreate.tsx
|
||||
- [x] src/admin/pages/AttendanceHistory.tsx
|
||||
- [x] src/admin/pages/AttendanceLocation.tsx
|
||||
- [x] src/admin/pages/AuditLog.tsx
|
||||
- [x] src/admin/pages/CompanySettings.tsx
|
||||
- [x] src/admin/pages/Dashboard.tsx
|
||||
- [x] src/admin/pages/InvoiceDetail.tsx
|
||||
- [x] src/admin/pages/Invoices.tsx
|
||||
- [x] src/admin/pages/LeaveApproval.tsx
|
||||
- [x] src/admin/pages/LeaveRequests.tsx
|
||||
- [x] src/admin/pages/Login.tsx
|
||||
- [x] src/admin/pages/NotFound.tsx
|
||||
- [x] src/admin/pages/Odin.tsx
|
||||
- [x] src/admin/pages/OfferDetail.tsx
|
||||
- [x] src/admin/pages/Offers.tsx
|
||||
- [x] src/admin/pages/OffersCustomers.tsx
|
||||
- [x] src/admin/pages/OffersTemplates.tsx
|
||||
- [x] src/admin/pages/OrderDetail.tsx
|
||||
- [x] src/admin/pages/Orders.tsx
|
||||
- [x] src/admin/pages/PlanWork.tsx
|
||||
- [x] src/admin/pages/ProjectDetail.tsx
|
||||
- [x] src/admin/pages/Projects.tsx
|
||||
- [x] src/admin/pages/ReceivedInvoices.tsx
|
||||
- [x] src/admin/pages/Settings.tsx
|
||||
- [x] src/admin/pages/Trips.tsx
|
||||
- [x] src/admin/pages/TripsAdmin.tsx
|
||||
- [x] src/admin/pages/TripsHistory.tsx
|
||||
- [x] src/admin/pages/UiKit.tsx
|
||||
- [x] src/admin/pages/Users.tsx
|
||||
- [x] src/admin/pages/Vehicles.tsx
|
||||
- [x] src/admin/pages/Warehouse.tsx
|
||||
- [x] src/admin/pages/WarehouseCategories.tsx
|
||||
- [x] src/admin/pages/WarehouseInventory.tsx
|
||||
- [x] src/admin/pages/WarehouseInventoryDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseInventoryForm.tsx
|
||||
- [x] src/admin/pages/WarehouseIssueDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseIssueForm.tsx
|
||||
- [x] src/admin/pages/WarehouseIssues.tsx
|
||||
- [x] src/admin/pages/WarehouseItemDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseItems.tsx
|
||||
- [x] src/admin/pages/WarehouseLocations.tsx
|
||||
- [x] src/admin/pages/WarehouseReceiptDetail.tsx
|
||||
- [x] src/admin/pages/WarehouseReceiptForm.tsx
|
||||
- [x] src/admin/pages/WarehouseReceipts.tsx
|
||||
- [x] src/admin/pages/WarehouseReports.tsx
|
||||
- [x] src/admin/pages/WarehouseReservations.tsx
|
||||
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
||||
|
||||
## src/admin/
|
||||
|
||||
- [x] src/admin/theme.test.ts
|
||||
- [x] src/admin/theme.ts
|
||||
|
||||
## src/admin/ui/
|
||||
|
||||
- [x] src/admin/ui/Alert.tsx
|
||||
- [x] src/admin/ui/AppShell.tsx
|
||||
- [x] src/admin/ui/Button.tsx
|
||||
- [x] src/admin/ui/Card.tsx
|
||||
- [x] src/admin/ui/Checkbox.tsx
|
||||
- [x] src/admin/ui/ConfirmDialog.tsx
|
||||
- [x] src/admin/ui/DataTable.tsx
|
||||
- [x] src/admin/ui/DateField.tsx
|
||||
- [x] src/admin/ui/EmptyState.tsx
|
||||
- [x] src/admin/ui/Field.tsx
|
||||
- [x] src/admin/ui/FileUpload.tsx
|
||||
- [x] src/admin/ui/FilterBar.tsx
|
||||
- [x] src/admin/ui/LoadingState.tsx
|
||||
- [x] src/admin/ui/Modal.tsx
|
||||
- [x] src/admin/ui/MonthField.tsx
|
||||
- [x] src/admin/ui/MuiProvider.tsx
|
||||
- [x] src/admin/ui/PageEnter.tsx
|
||||
- [x] src/admin/ui/PageHeader.tsx
|
||||
- [x] src/admin/ui/Pagination.tsx
|
||||
- [x] src/admin/ui/ProgressBar.tsx
|
||||
- [x] src/admin/ui/RichText.tsx
|
||||
- [x] src/admin/ui/Select.tsx
|
||||
- [x] src/admin/ui/SidebarNav.tsx
|
||||
- [x] src/admin/ui/StatCard.tsx
|
||||
- [x] src/admin/ui/StatusChip.tsx
|
||||
- [x] src/admin/ui/Tabs.tsx
|
||||
- [x] src/admin/ui/TextField.tsx
|
||||
- [x] src/admin/ui/ThemeToggle.tsx
|
||||
- [x] src/admin/ui/TimeField.tsx
|
||||
- [x] src/admin/ui/index.ts
|
||||
- [x] src/admin/ui/navData.tsx
|
||||
- [x] src/admin/ui/useDialogScrollLock.ts
|
||||
|
||||
## src/admin/utils/
|
||||
|
||||
- [x] src/admin/utils/api.ts
|
||||
- [x] src/admin/utils/attendanceHelpers.ts
|
||||
- [x] src/admin/utils/dashboardHelpers.ts
|
||||
- [x] src/admin/utils/formatters.ts
|
||||
|
||||
## src/config/
|
||||
|
||||
- [x] src/config/database.ts
|
||||
- [x] src/config/env.ts
|
||||
|
||||
## src/context/
|
||||
|
||||
- [x] src/context/ThemeContext.tsx
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/main.tsx
|
||||
|
||||
## src/middleware/
|
||||
|
||||
- [x] src/middleware/auth.ts
|
||||
- [x] src/middleware/security.ts
|
||||
|
||||
## src/routes/admin/
|
||||
|
||||
- [x] src/routes/admin/ai.ts
|
||||
- [x] src/routes/admin/attendance.ts
|
||||
- [x] src/routes/admin/audit-log.ts
|
||||
- [x] src/routes/admin/auth.ts
|
||||
- [x] src/routes/admin/bank-accounts.ts
|
||||
- [x] src/routes/admin/company-settings.ts
|
||||
- [x] src/routes/admin/customers.ts
|
||||
- [x] src/routes/admin/dashboard.ts
|
||||
- [x] src/routes/admin/invoices-pdf.ts
|
||||
- [x] src/routes/admin/invoices.ts
|
||||
- [x] src/routes/admin/leave-requests.ts
|
||||
- [x] src/routes/admin/offers-pdf.ts
|
||||
- [x] src/routes/admin/orders-pdf.ts
|
||||
- [x] src/routes/admin/orders.ts
|
||||
- [x] src/routes/admin/plan.ts
|
||||
- [x] src/routes/admin/profile.ts
|
||||
- [x] src/routes/admin/project-files.ts
|
||||
- [x] src/routes/admin/projects.ts
|
||||
- [x] src/routes/admin/quotations.ts
|
||||
- [x] src/routes/admin/received-invoices.ts
|
||||
- [x] src/routes/admin/roles.ts
|
||||
- [x] src/routes/admin/scope-templates.ts
|
||||
- [x] src/routes/admin/sessions.ts
|
||||
- [x] src/routes/admin/totp.ts
|
||||
- [x] src/routes/admin/trips.ts
|
||||
- [x] src/routes/admin/users.ts
|
||||
- [x] src/routes/admin/vehicles.ts
|
||||
- [x] src/routes/admin/warehouse.ts
|
||||
|
||||
## src/schemas/
|
||||
|
||||
- [x] src/schemas/ai.schema.ts
|
||||
- [x] src/schemas/attendance.schema.ts
|
||||
- [x] src/schemas/auth.schema.ts
|
||||
- [x] src/schemas/bank-accounts.schema.ts
|
||||
- [x] src/schemas/common.ts
|
||||
- [x] src/schemas/customers.schema.ts
|
||||
- [x] src/schemas/invoices.schema.ts
|
||||
- [x] src/schemas/leave-requests.schema.ts
|
||||
- [x] src/schemas/offers.schema.ts
|
||||
- [x] src/schemas/orders.schema.ts
|
||||
- [x] src/schemas/plan.schema.ts
|
||||
- [x] src/schemas/planCategory.schema.ts
|
||||
- [x] src/schemas/profile.schema.ts
|
||||
- [x] src/schemas/projects.schema.ts
|
||||
- [x] src/schemas/received-invoices.schema.ts
|
||||
- [x] src/schemas/roles.schema.ts
|
||||
- [x] src/schemas/scope-templates.schema.ts
|
||||
- [x] src/schemas/settings.schema.ts
|
||||
- [x] src/schemas/trips.schema.ts
|
||||
- [x] src/schemas/users.schema.ts
|
||||
- [x] src/schemas/vehicles.schema.ts
|
||||
- [x] src/schemas/warehouse.schema.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/server.ts
|
||||
|
||||
## src/services/
|
||||
|
||||
- [x] src/services/ai.service.ts
|
||||
- [x] src/services/attendance.service.ts
|
||||
- [x] src/services/audit.ts
|
||||
- [x] src/services/auth.ts
|
||||
- [x] src/services/exchange-rates.ts
|
||||
- [x] src/services/invoice-alerts.ts
|
||||
- [x] src/services/invoices.service.ts
|
||||
- [x] src/services/leave-notification.ts
|
||||
- [x] src/services/mailer.ts
|
||||
- [x] src/services/nas-file-manager.ts
|
||||
- [x] src/services/nas-financials-manager.ts
|
||||
- [x] src/services/nas-offers-manager.ts
|
||||
- [x] src/services/numbering.service.ts
|
||||
- [x] src/services/offers.service.ts
|
||||
- [x] src/services/orders.service.ts
|
||||
- [x] src/services/plan.service.ts
|
||||
- [x] src/services/planCategory.service.ts
|
||||
- [x] src/services/projects.service.ts
|
||||
- [x] src/services/system-settings.ts
|
||||
- [x] src/services/users.service.ts
|
||||
- [x] src/services/warehouse.service.ts
|
||||
|
||||
## src/types/
|
||||
|
||||
- [x] src/types/fastify.d.ts
|
||||
- [x] src/types/index.ts
|
||||
|
||||
## src/utils/
|
||||
|
||||
- [x] src/utils/czech-holidays.ts
|
||||
- [x] src/utils/date.ts
|
||||
- [x] src/utils/encryption.ts
|
||||
- [x] src/utils/html-to-pdf.ts
|
||||
- [x] src/utils/pagination.ts
|
||||
- [x] src/utils/planAuditDescription.ts
|
||||
- [x] src/utils/response.ts
|
||||
- [x] src/utils/totp.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/vite-env.d.ts
|
||||
|
||||
## ./
|
||||
|
||||
- [x] tsconfig.app.json
|
||||
- [x] tsconfig.json
|
||||
- [x] tsconfig.server.json
|
||||
- [x] vite.config.ts
|
||||
- [x] vitest.config.ts
|
||||
1330
REVIEW_FINDINGS.md
Normal file
1330
REVIEW_FINDINGS.md
Normal file
File diff suppressed because it is too large
Load Diff
1385
REVIEW_FIXES.md
Normal file
1385
REVIEW_FIXES.md
Normal file
File diff suppressed because it is too large
Load Diff
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Cross-Module Consistency Report: Offers, Invoices, Orders
|
||||
|
||||
> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
|
||||
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
|
||||
> Examination only — no code was changed. File:line refs are at the time of the audit.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.
|
||||
|
||||
## 2. Recommended Canonical Patterns
|
||||
|
||||
| Dimension | Standardize on | Why |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| List KPI cards | **Invoices** (`Invoices.tsx:420-505`) | Only module with a 4-card metrics grid; gives business context Orders/Offers lack |
|
||||
| List load/skeleton | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784) | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank |
|
||||
| List row state tinting | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx` | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
|
||||
| List status filter | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders) | Tabs show all options inline; Select scales — choose by option count |
|
||||
| Search-aware empty state | **Offers** (`Offers.tsx:840-855`) | Distinguishes search-empty (no CTA) from list-empty (with CTA) |
|
||||
| Detail read-only mode | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`) | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced |
|
||||
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`) | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct) |
|
||||
| Save-button loading | **Invoice/Order** (CircularProgress + text) | Clearer visual signal than Offers' text-only swap |
|
||||
| Detail guard placement | **OfferDetail** (all guards top-level, `:1087-1091`) | Flattest; no `if (!dataReady)` buried inside render branches |
|
||||
| Customer/supplier picker | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`) | Keyboard-accessible, searchable, Material-standard |
|
||||
| Boolean fields | **MUI `CheckboxField` kit** (as in OrdersReceived modal) | Themed, accessible; replaces raw `<input type=checkbox>` |
|
||||
| Notes fields | **RichEditor** (Offers/Orders already) | Uniform editing; future-proof for formatting |
|
||||
| `issued_by` | **Read-only, auto-filled** (Invoices, `:1881-1885`) | Immutable provenance tied to the issuing user |
|
||||
| Currency options | **Company settings** (Offers/Invoices) | Admin-configurable without code change |
|
||||
| Line-item labels/widths | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier |
|
||||
| Amount column header | **"Celkem"** (all but ReceivedInvoices' "Částka") | Single label for the same concept |
|
||||
|
||||
## 3. Inconsistencies by Dimension
|
||||
|
||||
### A. Tables & Lists
|
||||
|
||||
**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.
|
||||
|
||||
**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.
|
||||
|
||||
**MED — Orders missing list features that Invoices/Offers have.**
|
||||
|
||||
- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
|
||||
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
|
||||
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.
|
||||
|
||||
**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.
|
||||
|
||||
**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.
|
||||
|
||||
**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.
|
||||
|
||||
**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.
|
||||
|
||||
_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._
|
||||
|
||||
### B. Detail Buttons & Layout
|
||||
|
||||
**HIGH — Delete-button safety bugs + Orders feature gap.**
|
||||
|
||||
- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
|
||||
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
|
||||
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
|
||||
Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.
|
||||
|
||||
**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.
|
||||
|
||||
**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.
|
||||
|
||||
**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.
|
||||
|
||||
**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.
|
||||
|
||||
**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.
|
||||
|
||||
**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.
|
||||
|
||||
_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._
|
||||
|
||||
### C. Forms & Fields
|
||||
|
||||
**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.
|
||||
|
||||
**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.
|
||||
|
||||
**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.
|
||||
|
||||
**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.
|
||||
|
||||
**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.
|
||||
|
||||
**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.
|
||||
|
||||
_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._
|
||||
|
||||
### D. Line-Item Editors
|
||||
|
||||
Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).
|
||||
|
||||
**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
|
||||
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem` → `align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
|
||||
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
|
||||
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
|
||||
**LOW — Remove-button cell width.** Offers `3rem` → `2.5rem`. Effort: **S**.
|
||||
|
||||
_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._
|
||||
|
||||
## 4. Suggested Fix Order
|
||||
|
||||
**Batch 1 — Mechanical quick wins (low judgment, low risk).**
|
||||
|
||||
1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem`→`5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem`→`2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right`→`center`; "%DPH"→"DPH").
|
||||
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
|
||||
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
|
||||
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.
|
||||
|
||||
**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.
|
||||
|
||||
**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.
|
||||
|
||||
**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).
|
||||
|
||||
## 5. Already Consistent — Do Not Touch
|
||||
|
||||
- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
|
||||
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
|
||||
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
|
||||
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
|
||||
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.
|
||||
|
||||
**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Status/State Models + Draft Persistence (follow-up audit)
|
||||
|
||||
> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).
|
||||
|
||||
## Summary
|
||||
|
||||
The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.
|
||||
|
||||
## State-model comparison
|
||||
|
||||
| Document | Storage | Key language | #States | Transitions | Auto-transitions | Edit-lock | Color consistency |
|
||||
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
||||
| Offer (quotations) | `String(30)` def `active` | mixed (`active`/`ordered`/`invalidated`) | 3 | none (manual; `invalidateOffer`) | none | locked only when `invalidated` | **no label/color map** (tabs only) |
|
||||
| Customer Order (orders) | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4 | `VALID_TRANSITIONS` | → project status sync | items lock in `dokoncena`/`stornovana` | consistent list↔detail |
|
||||
| Issued Order (issued_orders) | **enum** def `draft` | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5 | `VALID_TRANSITIONS` | none | full lock outside `draft`/`sent` | consistent |
|
||||
| Issued Invoice (invoices) | `String(30)` def `issued` | English (`issued`/`overdue`/`paid`) | 3 | `VALID_TRANSITIONS` | `markOverdueInvoices()` + auto paid_date | full lock in `paid` | **DRIFTED**: `issued`=warning in list, =info in detail |
|
||||
| Received Invoice (received_invoices) | **enum** def `unpaid` | English (`unpaid`/`paid`) | 2 | implicit (no revert) | auto paid_date, month/year | locks in `paid` | consistent (list only) |
|
||||
|
||||
## Intentional (KEEP) vs Accidental (FIX)
|
||||
|
||||
**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.
|
||||
|
||||
**Accidental — fix:**
|
||||
|
||||
- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
|
||||
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
|
||||
- Divergent semantic color for the same status (`issued` = warning vs info).
|
||||
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
|
||||
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
|
||||
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
|
||||
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
|
||||
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
|
||||
- Arbitrary draft-banner visibility heuristics.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata`→`received`, `v_realizaci`→`in_progress`, `dokoncena`→`completed`, `stornovana`→`cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
|
||||
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
|
||||
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
|
||||
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
|
||||
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**
|
||||
|
||||
## Effort + risk
|
||||
|
||||
- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
|
||||
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
|
||||
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.
|
||||
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>
|
||||
```
|
||||
@@ -0,0 +1,680 @@
|
||||
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
|
||||
|
||||
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
|
||||
|
||||
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
|
||||
|
||||
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
|
||||
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
|
||||
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
|
||||
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
|
||||
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
|
||||
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
|
||||
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
|
||||
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
|
||||
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
|
||||
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install dependencies and reconcile React types
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json` (via npm)
|
||||
|
||||
- [ ] **Step 1: Install MUI + Emotion + X pickers**
|
||||
|
||||
Run (date-fns ^4 is already a dependency):
|
||||
|
||||
```bash
|
||||
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
|
||||
```
|
||||
|
||||
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
|
||||
|
||||
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
|
||||
|
||||
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
|
||||
|
||||
```bash
|
||||
npm install -D @types/react@^18.3 @types/react-dom@^18.3
|
||||
```
|
||||
|
||||
Expected: devDependencies now show `@types/react@^18.3.x`.
|
||||
|
||||
- [ ] **Step 3: Verify the project still type-checks and builds**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json
|
||||
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/theme.ts`
|
||||
- Test: `src/admin/theme.test.ts`
|
||||
|
||||
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/admin/theme.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { theme } from "./theme";
|
||||
|
||||
describe("MUI theme", () => {
|
||||
it("uses the brand fonts", () => {
|
||||
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
|
||||
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
|
||||
});
|
||||
|
||||
it("maps the brand red per color scheme", () => {
|
||||
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
|
||||
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
|
||||
});
|
||||
|
||||
it("maps the refreshed light canvas + paper", () => {
|
||||
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
|
||||
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("maps semantic colors per scheme", () => {
|
||||
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
|
||||
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
|
||||
});
|
||||
|
||||
it("uses the base radius of 10", () => {
|
||||
expect(theme.shape.borderRadius).toBe(10);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
|
||||
|
||||
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
|
||||
|
||||
Create `src/admin/theme.ts`:
|
||||
|
||||
```ts
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||
const FONT_MONO = "'DM Mono', Menlo, monospace";
|
||||
|
||||
export const theme = createTheme({
|
||||
cssVariables: {
|
||||
// Read the SAME attribute ThemeContext already writes (`data-theme`).
|
||||
// The "%s" placeholder is required; value must start with "." or "[".
|
||||
colorSchemeSelector: "[data-theme='%s']",
|
||||
},
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
mode: "light",
|
||||
primary: { main: "#c73030" },
|
||||
success: { main: "#15803d" },
|
||||
warning: { main: "#b45309" },
|
||||
error: { main: "#b91c1c" },
|
||||
info: { main: "#1d4ed8" },
|
||||
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||
divider: "rgba(0,0,0,0.1)",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
mode: "dark",
|
||||
primary: { main: "#d63031" },
|
||||
success: { main: "#22c55e" },
|
||||
warning: { main: "#f59e0b" },
|
||||
error: { main: "#ef4444" },
|
||||
info: { main: "#3b82f6" },
|
||||
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||
divider: "rgba(255,255,255,0.08)",
|
||||
},
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 10 },
|
||||
typography: {
|
||||
fontFamily: FONT_BODY,
|
||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
button: { textTransform: "none", fontWeight: 600 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-exported for tasks/components that need the mono stack.
|
||||
export const fonts = {
|
||||
body: FONT_BODY,
|
||||
heading: FONT_HEADING,
|
||||
mono: FONT_MONO,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/admin/theme.ts`
|
||||
- Test: `src/admin/theme.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add a failing assertion for the overrides**
|
||||
|
||||
Append to `src/admin/theme.test.ts` (inside the `describe`):
|
||||
|
||||
```ts
|
||||
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||
expect(btn?.borderRadius).toBe(999);
|
||||
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
||||
expect(card?.borderRadius).toBe(16);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add `components` to the `createTheme` call**
|
||||
|
||||
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
|
||||
|
||||
```ts
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999, paddingInline: "0.95rem" },
|
||||
containedPrimary: {
|
||||
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
||||
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
boxShadow:
|
||||
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: MUI provider + color-scheme sync
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
|
||||
- Create: `src/admin/ui/MuiProvider.tsx`
|
||||
- Modify: `src/admin/AdminApp.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the sync component**
|
||||
|
||||
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from "react";
|
||||
import { useColorScheme } from "@mui/material/styles";
|
||||
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||
|
||||
/**
|
||||
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
|
||||
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
|
||||
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
|
||||
*/
|
||||
export default function MuiColorSchemeSync() {
|
||||
const { theme } = useAppTheme();
|
||||
const { mode, setMode } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== theme) setMode(theme as "light" | "dark");
|
||||
}, [theme, mode, setMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the provider wrapper**
|
||||
|
||||
Create `src/admin/ui/MuiProvider.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { theme } from "../theme";
|
||||
import MuiColorSchemeSync from "./MuiColorSchemeSync";
|
||||
|
||||
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider theme={theme} defaultMode="dark">
|
||||
<MuiColorSchemeSync />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
|
||||
|
||||
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
|
||||
|
||||
```tsx
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
```
|
||||
|
||||
Then wrap the existing returned tree. Change:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<AuthProvider>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
```
|
||||
|
||||
and change the matching closing tag at the end of the JSX:
|
||||
|
||||
```tsx
|
||||
</AuthProvider>
|
||||
);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
</AuthProvider>
|
||||
</MuiProvider>
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the app builds and renders with no console errors**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
|
||||
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: First UI-kit wrappers + barrel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create the wrappers**
|
||||
|
||||
Create `src/admin/ui/Button.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiButton, { type ButtonProps } from "@mui/material/Button";
|
||||
|
||||
/** App Button: defaults to the brand primary contained style. */
|
||||
export default function Button(props: ButtonProps) {
|
||||
return <MuiButton variant="contained" color="primary" {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/admin/ui/Card.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
|
||||
export default function Card({ children, ...props }: CardProps) {
|
||||
return (
|
||||
<MuiCard {...props}>
|
||||
<CardContent>{children}</CardContent>
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/admin/ui/TextField.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
|
||||
/** App TextField: small + outlined + full width by default. */
|
||||
export default function TextField(props: TextFieldProps) {
|
||||
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the barrel**
|
||||
|
||||
Create `src/admin/ui/index.ts`:
|
||||
|
||||
```ts
|
||||
export { default as MuiProvider } from "./MuiProvider";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as Card } from "./Card";
|
||||
export { default as TextField } from "./TextField";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
|
||||
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Dev-only `/ui-kit` showcase route
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/pages/UiKit.tsx`
|
||||
- Modify: `src/admin/AdminApp.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the showcase page**
|
||||
|
||||
Create `src/admin/pages/UiKit.tsx`:
|
||||
|
||||
```tsx
|
||||
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { Button, Card, TextField } from "../ui";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
export default function UiKit() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
|
||||
<Typography variant="h4">UI Kit</Typography>
|
||||
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
|
||||
Theme: {theme}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3} sx={{ maxWidth: 520 }}>
|
||||
<Card>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Faktury
|
||||
</Typography>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
12 vystavených tento měsíc
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} mb={2}>
|
||||
<Chip label="Zaplaceno" color="success" size="small" />
|
||||
<Chip label="Po splatnosti" color="error" size="small" />
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Hledat fakturu"
|
||||
placeholder="Číslo nebo klient…"
|
||||
/>
|
||||
<Box mt={2}>
|
||||
<Button>Nová faktura</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route, DEV-only**
|
||||
|
||||
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
|
||||
|
||||
```tsx
|
||||
const UiKit = lazy(() => import("./pages/UiKit"));
|
||||
```
|
||||
|
||||
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
|
||||
|
||||
```tsx
|
||||
{
|
||||
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
|
||||
|
||||
- [ ] **Step 4: Manual + Playwright verification**
|
||||
|
||||
The user runs the dev server. Once it's up, verify at `/ui-kit`:
|
||||
|
||||
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
|
||||
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
|
||||
- No new console errors.
|
||||
|
||||
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
|
||||
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Token-level palette refresh (app-wide)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/admin/variables.css`
|
||||
|
||||
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
|
||||
|
||||
- [ ] **Step 1: Update the light canvas token**
|
||||
|
||||
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
|
||||
|
||||
```css
|
||||
--bg-primary: #f5f4f2;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
--bg-primary: #f4f3f1;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: succeeds.
|
||||
|
||||
- [ ] **Step 3: Manual check**
|
||||
|
||||
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/variables.css
|
||||
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Phase-0 release verification
|
||||
|
||||
**Files:** none (verification + tag)
|
||||
|
||||
- [ ] **Step 1: Full type-check, unit tests, and build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
|
||||
|
||||
- [ ] **Step 2: Backend test suite still green (server untouched)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
|
||||
|
||||
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
|
||||
|
||||
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
|
||||
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
|
||||
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
|
||||
- Light canvas is `#f4f3f1`.
|
||||
- Production build excludes the `/ui-kit` route.
|
||||
|
||||
- [ ] **Step 4: Tag/release per the project release process**
|
||||
|
||||
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 2–3), provider + `ScopedCssBaseline` + `ThemeContext`↔`setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
|
||||
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
|
||||
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.
|
||||
@@ -0,0 +1,357 @@
|
||||
# MUI Migration — Phase 1: AppShell — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
|
||||
|
||||
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
|
||||
|
||||
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
|
||||
|
||||
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||
|
||||
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
|
||||
|
||||
- Auth: `loading` → spinner; `!isAuthenticated` → `<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"` → `/`).
|
||||
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
|
||||
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
|
||||
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
|
||||
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
|
||||
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
|
||||
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
|
||||
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
|
||||
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
|
||||
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
|
||||
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
|
||||
|
||||
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract nav data into `navData.tsx`
|
||||
|
||||
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
|
||||
|
||||
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
|
||||
|
||||
```tsx
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
permission?: string | string[];
|
||||
matchPrefix?: string;
|
||||
matchAlso?: string[];
|
||||
matchExclude?: string[];
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
label: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
|
||||
];
|
||||
|
||||
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
|
||||
export function isItemActive(item: MenuItem, pathname: string): boolean {
|
||||
if (item.matchPrefix) {
|
||||
let active = pathname.startsWith(item.matchPrefix);
|
||||
if (active && item.matchExclude) {
|
||||
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
|
||||
}
|
||||
if (!active && item.matchAlso) {
|
||||
active = item.matchAlso.some((p) => pathname.startsWith(p));
|
||||
}
|
||||
return active;
|
||||
}
|
||||
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
|
||||
return true;
|
||||
}
|
||||
if (item.end) return pathname === item.path;
|
||||
return pathname.startsWith(item.path);
|
||||
}
|
||||
|
||||
/** Permission check: true if no permission, or any listed permission is held. */
|
||||
export function hasItemPermission(
|
||||
item: MenuItem,
|
||||
hasPermission: (p: string) => boolean,
|
||||
): boolean {
|
||||
if (!item.permission) return true;
|
||||
if (Array.isArray(item.permission)) {
|
||||
return item.permission.some((p) => hasPermission(p));
|
||||
}
|
||||
return hasPermission(item.permission);
|
||||
}
|
||||
```
|
||||
|
||||
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
|
||||
|
||||
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
|
||||
|
||||
- [ ] **Step 3: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
|
||||
|
||||
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SidebarNav.tsx` (MUI drawer content)
|
||||
|
||||
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
|
||||
|
||||
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
|
||||
|
||||
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
|
||||
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
|
||||
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
|
||||
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
|
||||
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
|
||||
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
|
||||
```tsx
|
||||
<ListItemButton
|
||||
component={NavLink}
|
||||
to={item.path}
|
||||
end={item.end}
|
||||
onClick={onNavigate}
|
||||
selected={isItemActive(item, location.pathname)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.25,
|
||||
py: 0.6,
|
||||
color: "text.secondary",
|
||||
"& svg": { width: 18, height: 18 },
|
||||
"&.Mui-selected, &.Mui-selected:hover": {
|
||||
bgcolor: "background.paper",
|
||||
color: "primary.main",
|
||||
fontWeight: 700,
|
||||
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
```
|
||||
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
|
||||
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
|
||||
|
||||
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
|
||||
|
||||
**Files:** Create `src/admin/ui/AppShell.tsx`.
|
||||
|
||||
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
|
||||
|
||||
```tsx
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||
import { setLogoutAlert } from "../utils/api";
|
||||
import SidebarNav from "./SidebarNav";
|
||||
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||
|
||||
const DRAWER_WIDTH = 248;
|
||||
|
||||
export default function AppShell() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useAppTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setMobileOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
</Box>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={
|
||||
loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={{
|
||||
duration: loggingOut ? 0.4 : 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
{/* Desktop permanent drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: "border-box",
|
||||
border: 0,
|
||||
bgcolor: "background.default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SidebarNav onLogout={handleLogout} />
|
||||
</Drawer>
|
||||
|
||||
{/* Mobile temporary drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: "block", md: "none" },
|
||||
"& .MuiDrawer-paper": {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SidebarNav
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* Main column */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Otevřít menu"
|
||||
sx={{ display: { md: "none" } }}
|
||||
>
|
||||
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
>
|
||||
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<ShortcutsHelp />
|
||||
</motion.div>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire `AppShell`, delete legacy chrome
|
||||
|
||||
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
|
||||
|
||||
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
|
||||
|
||||
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
|
||||
|
||||
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
|
||||
|
||||
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
|
||||
|
||||
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 2–4), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
|
||||
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
|
||||
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
|
||||
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||
@@ -0,0 +1,395 @@
|
||||
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
|
||||
|
||||
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
|
||||
|
||||
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
|
||||
|
||||
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||
|
||||
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage` → `<Forbidden/>`; empty state.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
|
||||
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
|
||||
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
|
||||
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
|
||||
| `src/admin/ui/index.ts` | Export the new components | Modify |
|
||||
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
|
||||
|
||||
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
|
||||
|
||||
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import MuiButton from "@mui/material/Button";
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
maxWidth?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
loading = false,
|
||||
submitDisabled = false,
|
||||
submitText = "Uložit",
|
||||
cancelText = "Zrušit",
|
||||
maxWidth = "sm",
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={loading ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth={maxWidth}
|
||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||
>
|
||||
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>{children}</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||
{cancelText}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={onSubmit}
|
||||
variant="contained"
|
||||
disabled={loading || submitDisabled}
|
||||
>
|
||||
{loading ? "Ukládám…" : submitText}
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
|
||||
|
||||
```tsx
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import MuiButton from "@mui/material/Button";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?: "primary" | "danger";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Potvrdit",
|
||||
cancelText = "Zrušit",
|
||||
confirmVariant = "primary",
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={loading ? undefined : onClose}
|
||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||
{cancelText}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
color={confirmVariant === "danger" ? "error" : "primary"}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Pracuji…" : confirmText}
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `DataTable` (dense MUI Table)
|
||||
|
||||
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
export interface DataColumn<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
align?: "left" | "right";
|
||||
mono?: boolean; // DM Mono numeric cell
|
||||
render: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataColumn<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string | number;
|
||||
rowInactive?: (row: T) => boolean;
|
||||
empty?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
rowInactive,
|
||||
empty,
|
||||
}: DataTableProps<T>) {
|
||||
if (rows.length === 0 && empty) return <>{empty}</>;
|
||||
return (
|
||||
<TableContainer sx={{ borderRadius: 2 }}>
|
||||
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((c) => (
|
||||
<TableCell
|
||||
key={c.key}
|
||||
align={c.align}
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontSize: ".64rem",
|
||||
fontWeight: 700,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{c.header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
hover
|
||||
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<TableCell
|
||||
key={c.key}
|
||||
align={c.align}
|
||||
sx={{
|
||||
fontSize: ".8rem",
|
||||
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
|
||||
}}
|
||||
>
|
||||
{c.render(row)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
|
||||
|
||||
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
|
||||
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Field controls (`Field` + `SwitchField`)
|
||||
|
||||
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
required,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
mb: 0.5,
|
||||
fontWeight: 600,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
|
||||
*
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{children}
|
||||
{error ? (
|
||||
<Typography variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
) : hint ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
|
||||
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate `Vehicles.tsx` onto the kit
|
||||
|
||||
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
|
||||
|
||||
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
|
||||
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
|
||||
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
|
||||
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
|
||||
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
|
||||
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
|
||||
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
|
||||
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
|
||||
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Phase-2 verification + final review
|
||||
|
||||
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
|
||||
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
|
||||
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 1–3), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
|
||||
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
|
||||
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
|
||||
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||
@@ -0,0 +1,362 @@
|
||||
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
|
||||
|
||||
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
|
||||
|
||||
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Component(s) | API |
|
||||
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
|
||||
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
|
||||
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
|
||||
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
|
||||
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
|
||||
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
|
||||
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
|
||||
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
|
||||
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
|
||||
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
|
||||
| `src/admin/ui/index.ts` | extend | export all new components |
|
||||
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
|
||||
|
||||
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
|
||||
|
||||
**Files:** Create the four files; modify `index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Select.tsx`**
|
||||
|
||||
```tsx
|
||||
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options?: SelectOption[];
|
||||
};
|
||||
|
||||
/** Dropdown styled like the kit TextField; composes inside <Field>. */
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
children,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<MuiTextField
|
||||
select
|
||||
size="small"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((o) => (
|
||||
<MenuItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))
|
||||
: children}
|
||||
</MuiTextField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `StatusChip.tsx`**
|
||||
|
||||
```tsx
|
||||
import Chip, { type ChipProps } from "@mui/material/Chip";
|
||||
|
||||
type Props = Omit<ChipProps, "color"> & {
|
||||
color?: "default" | "success" | "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
/** Semantic status chip. Pass onClick to make it a toggle. */
|
||||
export default function StatusChip({
|
||||
color = "default",
|
||||
size = "small",
|
||||
onClick,
|
||||
sx,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Chip
|
||||
color={color}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `Checkbox.tsx`**
|
||||
|
||||
```tsx
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MuiCheckbox from "@mui/material/Checkbox";
|
||||
|
||||
export function CheckboxField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<MuiCheckbox
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
|
||||
|
||||
- [ ] **Step 4: `Alert.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
|
||||
export default function Alert({
|
||||
severity = "info",
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
severity?: "success" | "error" | "warning" | "info";
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||
{children}
|
||||
</MuiAlert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
|
||||
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `Tabs` + `TabPanel`, `Pagination`
|
||||
|
||||
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Tabs.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
export interface TabDef {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
tabs: TabDef[];
|
||||
}) {
|
||||
return (
|
||||
<MuiTabs
|
||||
value={value}
|
||||
onChange={(_, v) => onChange(v)}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
minHeight: 44,
|
||||
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<Tab key={t.value} value={t.value} label={t.label} />
|
||||
))}
|
||||
</MuiTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabPanel({
|
||||
value,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
current: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (value !== current) return null;
|
||||
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `Pagination.tsx`**
|
||||
|
||||
```tsx
|
||||
import Box from "@mui/material/Box";
|
||||
import MuiPagination from "@mui/material/Pagination";
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
if (pageCount <= 1) return null;
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<MuiPagination
|
||||
page={page}
|
||||
count={pageCount}
|
||||
onChange={(_, p) => onChange(p)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
|
||||
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Sortable `DataTable`
|
||||
|
||||
**Files:** Modify `src/admin/ui/DataTable.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Extend the types:
|
||||
- `DataColumn<T>`: add `sortKey?: string;`
|
||||
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
|
||||
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
|
||||
|
||||
```tsx
|
||||
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||
// ...header cell:
|
||||
{
|
||||
c.sortKey && onSort ? (
|
||||
<TableSortLabel
|
||||
active={sortBy === c.sortKey}
|
||||
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
|
||||
onClick={() => onSort(c.sortKey!)}
|
||||
>
|
||||
{c.header}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
c.header
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
|
||||
|
||||
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `DateField` + LocalizationProvider
|
||||
|
||||
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
|
||||
|
||||
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
|
||||
|
||||
```tsx
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
|
||||
import { cs } from "date-fns/locale";
|
||||
// inside MuiProvider, wrap {children} (and the sync) :
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||
<MuiColorSchemeSync />
|
||||
{children}
|
||||
</LocalizationProvider>;
|
||||
```
|
||||
|
||||
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
|
||||
|
||||
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
|
||||
|
||||
```tsx
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { parseISO, format, isValid } from "date-fns";
|
||||
|
||||
export default function DateField({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
|
||||
const dateValue = value ? parseISO(value) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
value={dateValue && isValid(dateValue) ? dateValue : null}
|
||||
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
|
||||
format="dd.MM.yyyy"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** export `DateField` from `index.ts`.
|
||||
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Extend `/ui-kit` showcase + final review
|
||||
|
||||
**Files:** Modify `src/admin/pages/UiKit.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
|
||||
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
|
||||
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
|
||||
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
|
||||
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
|
||||
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.
|
||||
@@ -0,0 +1,67 @@
|
||||
# MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Migrate `src/admin/pages/Users.tsx` onto the MUI kit, mirroring the already-migrated `Vehicles.tsx`, preserving ALL data logic. Presentation only changes.
|
||||
|
||||
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button`, `Card`, `DataTable`, `Modal`, `ConfirmDialog`, `Field`, `TextField`, `SwitchField`, **`Select`** (new), **`StatusChip`** (new). MUI `Avatar`/`IconButton` used directly (as Vehicles uses `IconButton`).
|
||||
|
||||
**Reference:** `src/admin/pages/Vehicles.tsx` is the canonical migrated page — copy its structure (Box + motion.div header, `Button startIcon`, `Card` + `DataTable`, `Modal` with `Field`/`TextField`, `ConfirmDialog`). The Users data layer already exists; do not change it.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (dev server) is the user's.
|
||||
|
||||
---
|
||||
|
||||
## Behavior to preserve VERBATIM (from the scout)
|
||||
|
||||
- **Three mutations + invalidation:** `USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"]`. `saveUser` (PUT if `editingUser` else POST), `deleteUser` (DELETE), `toggleUser` (PUT `{id,is_active}`). Keep all `onSuccess` messages.
|
||||
- **Self-edit AuthContext sync (critical):** in `saveUser.onSuccess`, when `editingUser && currentUser && Number(editingUser.id)===Number(currentUser.id)`, call `updateUser({ username, email, fullName: \`${first_name} ${last_name}\`.trim() })`. Then `setShowModal(false); setEditingUser(null);`then`await new Promise(r=>setTimeout(r,300))` **(intentional delay — keep it)** before the success toast.
|
||||
- **Auth guard:** `const { user: currentUser, updateUser, hasPermission } = useAuth();` → `if (!hasPermission("users.view")) return <Forbidden/>;`
|
||||
- **Self-protection:** the delete action is **hidden** and the status toggle is **disabled** when `user.id === currentUser?.id`. These reference the live `currentUser` — keep them in the column render closures.
|
||||
- **Password:** required only on create (`!editingUser`); empty string on edit means "keep existing".
|
||||
- **Role dropdown:** options from `roleListOptions()` query; default to `roles[0]?.id` on create. `role_id` is `number|string` in form state.
|
||||
- **`FormData` shape:** `{ username, email, password, first_name, last_name, role_id, is_active }`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migrate `Users.tsx`
|
||||
|
||||
**Files:** Modify `src/admin/pages/Users.tsx` (read it first; it's the source of truth for the logic above).
|
||||
|
||||
- [ ] **Step 1: Read** `src/admin/pages/Users.tsx` fully and `src/admin/pages/Vehicles.tsx` (the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX.
|
||||
|
||||
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn`. From `@mui/material`: `Box, Typography, Avatar, IconButton`. Keep `motion` (framer-motion), `useQuery`, `roleListOptions`, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. **Remove** old imports: `FormModal`, `ConfirmModal`, `FormField`.
|
||||
|
||||
- [ ] **Step 3: Page shell** — mirror Vehicles: outer `Box`; `motion.div` header (`Typography variant="h4"` "Správa uživatelů" + `Button startIcon={PlusIcon}` "Přidat uživatele"); `motion.div` + `Card` wrapping the `DataTable`. Loading state keeps the `admin-spinner` markup.
|
||||
|
||||
- [ ] **Step 4: DataTable columns** (`DataColumn<User>[]`, defined inside the component so they close over `currentUser`/handlers):
|
||||
- **Uživatel:** composite render — MUI `Avatar` (initial of `full_name`/`username`, `bgcolor:"primary.main"`) + name (`Typography`) + `@username` (`Typography variant="caption" color="text.secondary"`).
|
||||
- **Email:** `render: (u) => u.email`.
|
||||
- **Role:** `StatusChip` colored by role — map `"admin"` → `color="warning"`, else `"default"`; label = `roleDisplay`/role name.
|
||||
- **Stav:** clickable `StatusChip` (`color={u.is_active?"success":"default"}`, `onClick` → `toggleActive(u)`), but **non-clickable / no onClick when `u.id===currentUser?.id`** (self-protection).
|
||||
- **Akce:** `IconButton` edit (always); `IconButton` delete (`color="error"`) **only when `u.id !== currentUser?.id`**. Use the same edit/delete SVGs as Vehicles.
|
||||
|
||||
- [ ] **Step 5: Add/Edit `Modal`** — `Field` + kit `TextField` for username/email/first_name/last_name; password `TextField type="password"` (label notes optional on edit); **`Select`** (inside a `Field` label="Role") for `role_id` with `options` from the roles query (`value: String(r.id), label: r.display_name||r.name`) — note `Select` is string-based, so store/compare `role_id` as string or `String(...)` at the boundary; `SwitchField` for `is_active`. Add a local `errors` state + validation in `handleSubmit` (required: username, email, first_name, last_name; password required when `!editingUser`) mirroring Vehicles, before `saveUser.mutateAsync`.
|
||||
|
||||
- [ ] **Step 6: Delete `ConfirmDialog`** — drop-in replacement for `ConfirmModal` (title "Smazat uživatele", message with the user's name, `confirmVariant="danger"`).
|
||||
|
||||
- [ ] **Step 7: Verify** `npx tsc -b --noEmit` clean; `npm run build` OK; `npx vitest run` (148). Drop all `admin-*` class names.
|
||||
|
||||
- [ ] **Step 8: Commit** (`feat(mui): migrate Users (Uživatelé) page onto MUI kit`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
- [ ] **Step 9: MANUAL (USER, dev server) — deferred:** list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Review + finish
|
||||
|
||||
- [ ] Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
|
||||
- [ ] finishing-a-development-branch after the user's visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- The role `Select` is string-based (per the Phase 3 review fix); convert `role_id` at the boundary (`String(r.id)` for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match).
|
||||
- Self-protection guards and the self-edit `updateUser` sync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor.
|
||||
- No `users.css` exists; Users uses shared classes — nothing to delete, it just stops using them.
|
||||
@@ -0,0 +1,85 @@
|
||||
# MUI Migration — Phase 5: Projects (Projekty) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Migrate `src/admin/pages/Projects.tsx` onto the MUI kit. It's a **paginated, server-sorted list** with a create modal (customer/user `Select`s, `DateField`s, multiline notes) and a delete confirm that has an extra "delete files from disk" checkbox. Mirror `Vehicles.tsx`/`Users.tsx`; preserve ALL data logic.
|
||||
|
||||
**Prereq kit tweak (Task 1):** `ConfirmDialog` needs to accept optional extra content below the message (for the delete-files checkbox), since `message` is a string.
|
||||
|
||||
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button, Card, DataTable` (now sortable), `Pagination`, `Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box/Typography/IconButton` direct.
|
||||
|
||||
**Reference:** `Vehicles.tsx` + `Users.tsx` (migrated). The Projects data layer is authoritative — keep it.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check is the user's.
|
||||
|
||||
---
|
||||
|
||||
## Behavior to preserve VERBATIM (from the scout)
|
||||
|
||||
- **List state:** `const { sort, order, handleSort, activeSort } = useTableSort("project_number")`; `search`/`page` state; **reset `setPage(1)` when search changes**. `usePaginatedQuery<Project>(projectListOptions({ search, sort, order, page }))` → `{ items, pagination, isPending, isFetching }`.
|
||||
- **isFetching dimming:** the list Card gets `sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}`.
|
||||
- **Create-only** (edit navigates to the `/projects/:id` detail route — NOT a modal). `createForm = { name, customer_id, responsible_user_id, status: "aktivni", start_date, end_date, notes }`, `createErrors`, `creating`. Dependent queries enabled only while the modal is open: `offerCustomersOptions()`, `userListOptions("projects.view")` (filter to `is_active`), `projectNextNumberOptions()`. `createMutation`: POST `/projects`, `invalidate: ["projects","orders","offers","invoices","attendance"]`, onSuccess close + "Projekt byl vytvořen". **Validation: only `name` required ("Zadejte název projektu").**
|
||||
- **Delete:** `deleteTarget`/`deleteFiles` state. `deleteMutation`: DELETE `/projects/:id`, `invalidate: ["projects","warehouse","orders","offers","invoices","attendance"]`, called as `mutateAsync({ id, delete_files: deleteFiles })`, onSuccess toast `data?.message || "Projekt byl smazán"` + reset.
|
||||
- **Permissions:** `projects.view` → `<Forbidden/>`; `projects.create` hides the add button; `projects.delete` hides the per-row delete; delete also hidden when `p.order_id` (order-linked projects can't be manually deleted).
|
||||
- **Status constants:** `STATUS_LABELS = { aktivni:"Aktivní", dokonceny:"Dokončený", zruseny:"Zrušený" }`. Map to `StatusChip` colors: `aktivni`→`success`, `dokonceny`→`info`, `zruseny`→`default`.
|
||||
- **Next number:** `nextNumberQuery.data?.number ?? nextNumberQuery.data?.next_number ?? "…"`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `ConfirmDialog` with optional extra content
|
||||
|
||||
**Files:** Modify `src/admin/ui/ConfirmDialog.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Add `children?: ReactNode` to `ConfirmDialogProps` (import `type { ReactNode }`). Render it in `DialogContent` BELOW the message (live — not frozen, so the checkbox stays interactive):
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogContentText>{shown.message}</DialogContentText>
|
||||
{children}
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Verify `tsc -b` clean; `build:client` OK; existing callers (Vozidla, Users) unaffected (no `children` → unchanged). **Commit** (`feat(mui): ConfirmDialog optional children (extra content below message)`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate `Projects.tsx`
|
||||
|
||||
**Files:** Modify `src/admin/pages/Projects.tsx` (read it first — authoritative for the logic above).
|
||||
|
||||
- [ ] **Step 1: Read** `src/admin/pages/Projects.tsx` fully + `Users.tsx`/`Vehicles.tsx`. Keep ALL hooks/queries/mutations/handlers/permission-guards/state. Change only presentation.
|
||||
|
||||
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box, Typography, IconButton` direct. Keep `motion`, `useNavigate`, the query options, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. Remove `FormModal`/`ConfirmModal`/`FormField`/`AdminDatePicker`/legacy `Pagination` imports.
|
||||
|
||||
- [ ] **Step 3: Page shell** — Box + motion.div header (`Typography variant="h4"` "Projekty" + `Button startIcon={PlusIcon}` "Nový projekt", hidden without `projects.create`). A search `TextField` (no `Field` wrapper) that sets search + `setPage(1)`.
|
||||
|
||||
- [ ] **Step 4: List** — `Card` (with the `isFetching` opacity sx) wrapping a sortable `DataTable<Project>`:
|
||||
- Wire sort: `sortBy={sort} sortDir={order} onSort={handleSort}`. Give sortable columns a `sortKey` (e.g. `project_number`, `name` — match the server's accepted sort keys from the original `SortIcon` usage).
|
||||
- Columns: project number (`mono`, `sortKey`), name (`sortKey`), customer, responsible user, status (`StatusChip` per the color map), linked order number (if `order_id`), start/end dates (`mono`, via the existing date formatter), actions — a detail link/`IconButton` that `navigate(\`/projects/${p.id}\`)`for edit, and a delete`IconButton color="error"`shown only when`hasPermission("projects.delete") && !p.order_id`.
|
||||
- Below the table: `<Pagination page={page} pageCount={pagination?.totalPages ?? 1} onChange={setPage} />` (use the actual page-count field from `pagination` — check the shape).
|
||||
|
||||
- [ ] **Step 5: Create `Modal`** (`isOpen={showCreate}`, `loading={creating}`, title "Nový projekt", subtitle/hint with the next number): `Field`+`TextField` for name (required, error from `createErrors`); `Select` (in a `Field`) for customer (`offerCustomersOptions`) and responsible user (active users) — options `{ value: String(id), label }`, convert at the boundary; `Select` for status (the STATUS_LABELS options); two `DateField`s for start/end (string in/out — matches `createForm.start_date`); `TextField multiline minRows={3}` for notes. Validation: only name required.
|
||||
|
||||
- [ ] **Step 6: Delete `ConfirmDialog`** — title "Smazat projekt", message with the project name, `confirmVariant="danger"`, and as **children** a `CheckboxField` "Smazat i soubory z disku" bound to `deleteFiles`. `onConfirm` calls the existing delete handler (`mutateAsync({ id, delete_files: deleteFiles })`).
|
||||
|
||||
- [ ] **Step 7: Verify** `tsc -b` clean; `npm run build` OK; `npx vitest run` (148). Drop `admin-*` classes. (Projects has no own CSS file; it uses shared classes.)
|
||||
|
||||
- [ ] **Step 8: Commit** (`feat(mui): migrate Projects (Projekty) page onto MUI kit`; + trailer).
|
||||
|
||||
- [ ] **Step 9: MANUAL (USER) — deferred:** list renders + sorts (click headers) + paginates + dims while fetching; create modal (customer/user/status selects, date pickers, notes; name-required validation; next number shown) saves; delete confirm with the files checkbox works; order-linked projects show no delete; permission gating; dark+light.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Review + finish
|
||||
|
||||
- [ ] Spec-compliance review (server sort/pagination wiring, the create `invalidate` arrays, name-only validation, delete `{id, delete_files}` body + the checkbox, permission/order-link gating, status color map, the Select string boundaries) → code-quality review.
|
||||
- [ ] finishing-a-development-branch after the user's visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- This is the first page to exercise the **sortable DataTable** + **Pagination** + **DateField** + **Select** together — confirm the sort keys match what the server accepts and the pagination page-count field name is correct (read the query options / a working paginated page).
|
||||
- `Select` is string-based; convert customer/user/status ids at the form boundary (`String(...)` for options/value; send what the server expects).
|
||||
- The delete-files checkbox rides in `ConfirmDialog` `children` (Task 1) — it's live (not frozen), so it toggles while open; it resets via the caller on close.
|
||||
- Edit is a route navigation (`/projects/:id`), NOT a modal — don't add an edit modal.
|
||||
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
@@ -0,0 +1,891 @@
|
||||
# Bulk Plan Creation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work).
|
||||
|
||||
**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration.
|
||||
|
||||
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5).
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Backend**
|
||||
|
||||
- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`.
|
||||
- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`).
|
||||
- `src/routes/admin/plan.ts` — add `POST /entries/bulk`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- `src/admin/components/BulkPlanModal.tsx` — new presentational modal.
|
||||
- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation.
|
||||
- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring.
|
||||
|
||||
**Tests**
|
||||
|
||||
- `src/__tests__/plan.test.ts` — `bulkCreateEntries` service tests.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — bulk endpoint + service
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/schemas/plan.schema.ts`
|
||||
- Modify: `src/services/plan.service.ts`
|
||||
- Modify: `src/routes/admin/plan.ts`
|
||||
- Test: `src/__tests__/plan.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing service tests**
|
||||
|
||||
Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file.
|
||||
|
||||
```ts
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulkCreateEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.bulkCreateEntries", () => {
|
||||
it("creates one continuous range per employee when weekends are included", async () => {
|
||||
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||
// range 06-01..06-03 covering all 3 days.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-wknd`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(1);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
expect(res.data.users).toBe(1);
|
||||
});
|
||||
|
||||
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-05",
|
||||
include_weekends: false,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-week`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
});
|
||||
|
||||
it("skips days already at the cap and splits the range around them", async () => {
|
||||
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2096-07-02"),
|
||||
date_to: new Date("2096-07-02"),
|
||||
category: "work",
|
||||
note: `${N}cap${i}`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-07-01",
|
||||
date_to: "2096-07-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-cap`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
expect(res.data.skipped_days).toBe(1);
|
||||
});
|
||||
|
||||
it("aggregates across multiple employees", async () => {
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId, noPermUserId],
|
||||
date_from: "2096-08-03", // Friday
|
||||
date_to: "2096-08-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-multi`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.users).toBe(2);
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||
const empty = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in empty && empty.status).toBe(400);
|
||||
|
||||
const past = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in past && past.status).toBe(403);
|
||||
|
||||
const badRange = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-10",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badRange && badRange.status).toBe(400);
|
||||
|
||||
const badCat = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "__nope__",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badCat && badCat.status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||
Expected: FAIL — `bulkCreateEntries` is not exported / not a function.
|
||||
|
||||
- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`**
|
||||
|
||||
Add at the end of `src/services/plan.service.ts` (after `listOverrides`):
|
||||
|
||||
```ts
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk entry creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bulk-assign a project/category to many employees over a date range. For each
|
||||
* employee, walk the range, keep the days that pass the weekday filter
|
||||
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
|
||||
* group contiguous kept days into runs, and create one range entry per run by
|
||||
* reusing createEntry (so cap / past-date / category validation stays DRY).
|
||||
*
|
||||
* Days that pass the weekday filter but are already at the cap are counted in
|
||||
* skipped_days (and split the range). Weekend days, when excluded, are out of
|
||||
* scope and are NOT counted as skipped.
|
||||
*/
|
||||
export async function bulkCreateEntries(
|
||||
input: {
|
||||
user_ids: number[];
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
include_weekends: boolean;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
},
|
||||
actorUserId: number,
|
||||
): Promise<
|
||||
Result<{
|
||||
created_entries: number;
|
||||
created_days: number;
|
||||
skipped_days: number;
|
||||
users: number;
|
||||
}>
|
||||
> {
|
||||
if (input.user_ids.length === 0) {
|
||||
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
|
||||
}
|
||||
if (input.date_to < input.date_from) {
|
||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||
}
|
||||
const lockFrom = assertNotPastDate(input.date_from, false);
|
||||
if (lockFrom) return lockFrom;
|
||||
const lockTo = assertNotPastDate(input.date_to, false);
|
||||
if (lockTo) return lockTo;
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
|
||||
const from = toDateOnly(input.date_from);
|
||||
const to = toDateOnly(input.date_to);
|
||||
|
||||
// The day list for the whole range (UTC midnights), built once.
|
||||
const days: Date[] = [];
|
||||
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
days.push(new Date(d));
|
||||
}
|
||||
|
||||
let createdEntries = 0;
|
||||
let createdDays = 0;
|
||||
let skippedDays = 0;
|
||||
|
||||
for (const userId of input.user_ids) {
|
||||
// Per-day cap counts from this user's existing active entries.
|
||||
const existing = await prisma.work_plan_entries.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
is_deleted: false,
|
||||
date_from: { lte: to },
|
||||
date_to: { gte: from },
|
||||
},
|
||||
select: { date_from: true, date_to: true },
|
||||
});
|
||||
const countFor = (day: Date): number =>
|
||||
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
|
||||
|
||||
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
|
||||
const eligible: boolean[] = [];
|
||||
for (const day of days) {
|
||||
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
if (isWeekend && !input.include_weekends) {
|
||||
eligible.push(false);
|
||||
continue;
|
||||
}
|
||||
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
|
||||
skippedDays++;
|
||||
eligible.push(false);
|
||||
continue;
|
||||
}
|
||||
eligible.push(true);
|
||||
}
|
||||
|
||||
// Group contiguous eligible days into runs; create one range per run.
|
||||
let runStart = -1;
|
||||
for (let i = 0; i <= days.length; i++) {
|
||||
const ok = i < days.length && eligible[i];
|
||||
if (ok && runStart === -1) {
|
||||
runStart = i;
|
||||
} else if (!ok && runStart !== -1) {
|
||||
const segFrom = days[runStart].toISOString().slice(0, 10);
|
||||
const segTo = days[i - 1].toISOString().slice(0, 10);
|
||||
const runDays = i - runStart;
|
||||
const res = await createEntry(
|
||||
{
|
||||
user_id: userId,
|
||||
date_from: segFrom,
|
||||
date_to: segTo,
|
||||
project_id: input.project_id ?? null,
|
||||
category: input.category,
|
||||
note: input.note,
|
||||
},
|
||||
actorUserId,
|
||||
false,
|
||||
);
|
||||
if ("data" in res) {
|
||||
createdEntries++;
|
||||
createdDays += runDays;
|
||||
} else {
|
||||
// Rare race (e.g. the cap filled concurrently). Treat as skipped.
|
||||
skippedDays += runDays;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
created_entries: createdEntries,
|
||||
created_days: createdDays,
|
||||
skipped_days: skippedDays,
|
||||
users: input.user_ids.length,
|
||||
},
|
||||
oldData: null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the service tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||
Expected: PASS (including the 5 new bulk tests).
|
||||
|
||||
- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`**
|
||||
|
||||
Append to `src/schemas/plan.schema.ts`:
|
||||
|
||||
```ts
|
||||
export const BulkPlanEntrySchema = z
|
||||
.object({
|
||||
user_ids: z
|
||||
.array(intFromForm)
|
||||
.min(1, "Vyberte alespoň jednoho zaměstnance"),
|
||||
date_from: isoDate,
|
||||
date_to: isoDate,
|
||||
include_weekends: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((v) => v === true || v === "true" || v === "1")
|
||||
.default(false),
|
||||
project_id: z
|
||||
.union([z.number(), z.string(), z.null()])
|
||||
.transform((v) => (v === null ? null : Number(v)))
|
||||
.nullish(),
|
||||
category: planCategoryEnum.default("work"),
|
||||
note: z.string().max(500, "Maximálně 500 znaků").default(""),
|
||||
})
|
||||
.refine((v) => v.date_to >= v.date_from, {
|
||||
message: "Datum do musí být stejné nebo po datumu od",
|
||||
path: ["date_to"],
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the `POST /entries/bulk` route**
|
||||
|
||||
In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`):
|
||||
|
||||
```ts
|
||||
// --- POST /plan/entries/bulk ---
|
||||
app.post(
|
||||
"/entries/bulk",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(BulkPlanEntrySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const result = await bulkCreateEntries(
|
||||
body.data!,
|
||||
request.authData!.userId,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "work_plan_entry",
|
||||
description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`,
|
||||
});
|
||||
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add an HTTP route test**
|
||||
|
||||
Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`:
|
||||
|
||||
```ts
|
||||
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-01",
|
||||
date_to: "2095-06-01",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-forbidden`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||
date_to: "2095-06-05",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-http`,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.created_days).toBe(1);
|
||||
expect(body.data.users).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Gate**
|
||||
|
||||
Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts
|
||||
git commit -m "feat(plan): bulk entry creation endpoint + service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend — bulk modal + wiring
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/components/BulkPlanModal.tsx`
|
||||
- Modify: `src/admin/hooks/usePlanWork.ts`
|
||||
- Modify: `src/admin/pages/PlanWork.tsx`
|
||||
|
||||
Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit).
|
||||
|
||||
- [ ] **Step 1: Create `BulkPlanModal.tsx`**
|
||||
|
||||
```tsx
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Modal,
|
||||
Field,
|
||||
DateField,
|
||||
Select,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
} from "../ui";
|
||||
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
|
||||
export interface BulkPlanForm {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
is_range: boolean;
|
||||
user_ids: string[];
|
||||
include_weekends: boolean;
|
||||
project_id: string;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
form: BulkPlanForm;
|
||||
setForm: (form: BulkPlanForm) => void;
|
||||
users: PlanUser[];
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
onSubmit: () => void;
|
||||
submitting: boolean;
|
||||
toggleUser: (userId: number) => void;
|
||||
toggleAllUsers: () => void;
|
||||
}
|
||||
|
||||
export default function BulkPlanModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
form,
|
||||
setForm,
|
||||
users,
|
||||
projects,
|
||||
categories,
|
||||
onSubmit,
|
||||
submitting,
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: Props) {
|
||||
const activeCategories = categories.filter((c) => c.is_active);
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Hromadné přiřazení"
|
||||
maxWidth="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitText="Vytvořit"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.25, mb: 2 }}
|
||||
>
|
||||
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už
|
||||
mají 3 záznamy, se přeskočí.
|
||||
</Typography>
|
||||
|
||||
<Field label="Datum od">
|
||||
<DateField
|
||||
value={form.date_from}
|
||||
onChange={(val) => setForm({ ...form, date_from: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={form.is_range}
|
||||
onChange={(checked) => setForm({ ...form, is_range: checked })}
|
||||
/>
|
||||
</Field>
|
||||
{form.is_range && (
|
||||
<Field label="Datum do">
|
||||
<DateField
|
||||
value={form.date_to}
|
||||
onChange={(val) => setForm({ ...form, date_to: val })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Víkendy">
|
||||
<CheckboxField
|
||||
label="Zahrnout víkendy"
|
||||
checked={form.include_weekends}
|
||||
onChange={(checked) =>
|
||||
setForm({ ...form, include_weekends: checked })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Zaměstnanci
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
variant="caption"
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.25,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<CheckboxField
|
||||
key={u.id}
|
||||
label={u.full_name}
|
||||
checked={form.user_ids.includes(String(u.id))}
|
||||
onChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Field label="Projekt">
|
||||
<Select
|
||||
value={form.project_id}
|
||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={form.category}
|
||||
onChange={(val) => setForm({ ...form, category: val })}
|
||||
options={activeCategories.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Text poznámky (volitelný)">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`**
|
||||
|
||||
After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add:
|
||||
|
||||
```ts
|
||||
const bulkCreate = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/entries/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// No optimistic patch — bulk can touch many cells; just invalidate and
|
||||
// let the grid refetch the authoritative state.
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.).
|
||||
|
||||
- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`**
|
||||
|
||||
(a) Add imports near the other component imports:
|
||||
|
||||
```ts
|
||||
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
|
||||
```
|
||||
|
||||
(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.).
|
||||
|
||||
(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`):
|
||||
|
||||
```ts
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false);
|
||||
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
|
||||
const today = todayIsoLocal();
|
||||
return {
|
||||
date_from: today,
|
||||
date_to: today,
|
||||
is_range: false,
|
||||
user_ids: [],
|
||||
include_weekends: false,
|
||||
project_id: "",
|
||||
category: "work",
|
||||
note: "",
|
||||
};
|
||||
});
|
||||
|
||||
const planUsers = grid?.users ?? [];
|
||||
|
||||
const toggleBulkUser = useCallback((userId: number) => {
|
||||
setBulkForm((prev) => ({
|
||||
...prev,
|
||||
user_ids: prev.user_ids.includes(String(userId))
|
||||
? prev.user_ids.filter((u) => u !== String(userId))
|
||||
: [...prev.user_ids, String(userId)],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleAllBulkUsers = useCallback(() => {
|
||||
const allIds = (grid?.users ?? []).map((u) => String(u.id));
|
||||
setBulkForm((prev) => ({
|
||||
...prev,
|
||||
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
|
||||
}));
|
||||
}, [grid]);
|
||||
|
||||
const submitBulk = useCallback(async () => {
|
||||
if (bulkForm.user_ids.length === 0) return;
|
||||
setBulkSubmitting(true);
|
||||
try {
|
||||
const data: any = await bulkCreate.mutateAsync({
|
||||
user_ids: bulkForm.user_ids.map(Number),
|
||||
date_from: bulkForm.date_from,
|
||||
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
|
||||
include_weekends: bulkForm.include_weekends,
|
||||
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
|
||||
category: bulkForm.category,
|
||||
note: bulkForm.note,
|
||||
});
|
||||
const skipped =
|
||||
data?.skipped_days > 0
|
||||
? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).`
|
||||
: "";
|
||||
alert.success(
|
||||
`Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`,
|
||||
);
|
||||
setBulkOpen(false);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||
} finally {
|
||||
setBulkSubmitting(false);
|
||||
}
|
||||
}, [bulkForm, bulkCreate, alert]);
|
||||
```
|
||||
|
||||
(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar `<Box>`):
|
||||
|
||||
```tsx
|
||||
{
|
||||
canEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setBulkOpen(true)}
|
||||
>
|
||||
Hromadné přiřazení
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(e) Render the modal alongside `<PlanCategoriesModal ... />`:
|
||||
|
||||
```tsx
|
||||
<BulkPlanModal
|
||||
isOpen={bulkOpen}
|
||||
onClose={() => setBulkOpen(false)}
|
||||
form={bulkForm}
|
||||
setForm={setBulkForm}
|
||||
users={planUsers}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
onSubmit={submitBulk}
|
||||
submitting={bulkSubmitting}
|
||||
toggleUser={toggleBulkUser}
|
||||
toggleAllUsers={toggleAllBulkUsers}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate + Chrome check**
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only Mon–Fri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx
|
||||
git commit -m "feat(plan): bulk assignment modal on /plan-work"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Release v2.0.6
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Bump version** to `"version": "2.0.6"` in `package.json`.
|
||||
|
||||
- [ ] **Step 2: Full gate**
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npx vitest run
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: tsc exit 0, vitest all pass, build success.
|
||||
|
||||
- [ ] **Step 3: Merge to master + commit + tag**
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git merge --ff-only feat/bulk-plan-creation
|
||||
git add package.json
|
||||
git commit -m "chore(release): v2.0.6 — bulk plan creation"
|
||||
git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation"
|
||||
```
|
||||
|
||||
(If implementation happened directly on master, skip the checkout/merge.)
|
||||
|
||||
- [ ] **Step 4: Push to Gitea**
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
git push origin v2.0.6
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)**
|
||||
|
||||
```bash
|
||||
tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts
|
||||
scp app-ts-2.0.6.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
|
||||
```
|
||||
|
||||
No migration ships in this release → `prisma migrate deploy` reports "No pending migrations".
|
||||
|
||||
- [ ] **Step 6: Health check**
|
||||
|
||||
```bash
|
||||
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session'
|
||||
```
|
||||
|
||||
Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.6 online.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those.
|
||||
- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped.
|
||||
- **No DB migration.** Don't run any prisma migrate command.
|
||||
- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check.
|
||||
- **Do not deploy to production without explicit confirmation** (Task 3, Steps 5–6).
|
||||
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
File diff suppressed because it is too large
Load Diff
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
@@ -0,0 +1,792 @@
|
||||
# Odin Multi-Conversation Chat — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
|
||||
|
||||
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
|
||||
|
||||
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
|
||||
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
|
||||
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
|
||||
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
|
||||
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
|
||||
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
|
||||
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
|
||||
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
|
||||
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
|
||||
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
|
||||
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
|
||||
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
|
||||
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
|
||||
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
|
||||
|
||||
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate` → `npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Data model + migration
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `prisma/schema.prisma`
|
||||
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
|
||||
|
||||
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
|
||||
|
||||
```prisma
|
||||
model ai_conversations {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
title String @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||
messages ai_chat_messages[]
|
||||
|
||||
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||
}
|
||||
|
||||
model ai_chat_messages {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
conversation_id Int
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
|
||||
|
||||
```sql
|
||||
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||
|
||||
CREATE TABLE `ai_conversations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `ai_chat_messages`
|
||||
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||
|
||||
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||
FROM `ai_chat_messages`
|
||||
GROUP BY `user_id`;
|
||||
|
||||
UPDATE `ai_chat_messages` m
|
||||
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||
SET m.`conversation_id` = c.`id`;
|
||||
|
||||
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||
ALTER TABLE `ai_chat_messages`
|
||||
DROP INDEX `idx_ai_chat_user`,
|
||||
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Controller applies the migration** (not the implementer):
|
||||
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
|
||||
- `npx prisma generate` — expect "Generated Prisma Client".
|
||||
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
|
||||
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend — conversation service, schemas, routes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/services/ai.service.ts`
|
||||
- Modify: `src/schemas/ai.schema.ts`
|
||||
- Modify: `src/routes/admin/ai.ts`
|
||||
|
||||
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
|
||||
|
||||
```ts
|
||||
// ── Conversations (server-side, per user) ─────────────────────────────────
|
||||
export interface StoredChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
}
|
||||
export interface ConversationSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
|
||||
const MESSAGE_LIMIT = 200; // cap a single thread read
|
||||
|
||||
export async function listConversations(
|
||||
userId: number,
|
||||
): Promise<ConversationSummary[]> {
|
||||
return prisma.ai_conversations.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { id: "asc" }, // stable tab order
|
||||
select: { id: true, title: true, updated_at: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
userId: number,
|
||||
title?: string,
|
||||
): Promise<ConversationSummary> {
|
||||
return prisma.ai_conversations.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
|
||||
},
|
||||
select: { id: true, title: true, updated_at: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ownership-checked fetch; null when not found / not owned. */
|
||||
async function ownConversation(userId: number, convId: number) {
|
||||
return prisma.ai_conversations.findFirst({
|
||||
where: { id: convId, user_id: userId },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConversationMessages(
|
||||
userId: number,
|
||||
convId: number,
|
||||
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
|
||||
if (!(await ownConversation(userId, convId)))
|
||||
return { error: "Konverzace nenalezena", status: 404 };
|
||||
const rows = await prisma.ai_chat_messages.findMany({
|
||||
where: { conversation_id: convId },
|
||||
orderBy: { id: "desc" },
|
||||
take: MESSAGE_LIMIT,
|
||||
select: { role: true, content: true, created_at: true },
|
||||
});
|
||||
return { data: rows.reverse() };
|
||||
}
|
||||
|
||||
export async function appendConversationMessages(
|
||||
userId: number,
|
||||
convId: number,
|
||||
messages: { role: string; content: string }[],
|
||||
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||
const conv = await ownConversation(userId, convId);
|
||||
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
|
||||
if (messages.length > 0) {
|
||||
await prisma.ai_chat_messages.createMany({
|
||||
data: messages.map((m) => ({
|
||||
user_id: userId,
|
||||
conversation_id: convId,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
});
|
||||
}
|
||||
// Auto-title from the first user message (only while still the default), and
|
||||
// always bump updated_at.
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
|
||||
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
|
||||
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
|
||||
if (t) data.title = t;
|
||||
}
|
||||
await prisma.ai_conversations.update({ where: { id: convId }, data });
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
|
||||
export async function renameConversation(
|
||||
userId: number,
|
||||
convId: number,
|
||||
title: string,
|
||||
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
|
||||
if (!(await ownConversation(userId, convId)))
|
||||
return { error: "Konverzace nenalezena", status: 404 };
|
||||
const updated = await prisma.ai_conversations.update({
|
||||
where: { id: convId },
|
||||
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
|
||||
select: { id: true, title: true, updated_at: true },
|
||||
});
|
||||
return { data: updated };
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
userId: number,
|
||||
convId: number,
|
||||
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||
if (!(await ownConversation(userId, convId)))
|
||||
return { error: "Konverzace nenalezena", status: 404 };
|
||||
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
|
||||
|
||||
```ts
|
||||
export const AiCreateConversationSchema = z.object({
|
||||
title: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const AiRenameConversationSchema = z.object({
|
||||
title: z.string().min(1, "Název je povinný").max(255),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
|
||||
|
||||
```ts
|
||||
// GET /conversations — this user's conversations (stable order)
|
||||
app.get(
|
||||
"/conversations",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const conversations = await listConversations(request.authData!.userId);
|
||||
return success(reply, { conversations });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /conversations — create
|
||||
app.post(
|
||||
"/conversations",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(AiCreateConversationSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const conversation = await createConversation(
|
||||
request.authData!.userId,
|
||||
body.data.title,
|
||||
);
|
||||
return success(reply, { conversation });
|
||||
},
|
||||
);
|
||||
|
||||
// GET /conversations/:id/messages
|
||||
app.get(
|
||||
"/conversations/:id/messages",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as { id: string }).id, reply);
|
||||
if (id === null) return;
|
||||
const result = await getConversationMessages(request.authData!.userId, id);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
return success(reply, { messages: result.data });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /conversations/:id/messages — append turns
|
||||
app.post(
|
||||
"/conversations/:id/messages",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as { id: string }).id, reply);
|
||||
if (id === null) return;
|
||||
const body = parseBody(AiHistoryAppendSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const result = await appendConversationMessages(
|
||||
request.authData!.userId,
|
||||
id,
|
||||
body.data.messages,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
return success(reply, result.data);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /conversations/:id — rename
|
||||
app.patch(
|
||||
"/conversations/:id",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as { id: string }).id, reply);
|
||||
if (id === null) return;
|
||||
const body = parseBody(AiRenameConversationSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const result = await renameConversation(
|
||||
request.authData!.userId,
|
||||
id,
|
||||
body.data.title,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
return success(reply, { conversation: result.data });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /conversations/:id
|
||||
app.delete(
|
||||
"/conversations/:id",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = parseId((request.params as { id: string }).id, reply);
|
||||
if (id === null) return;
|
||||
const result = await deleteConversation(request.authData!.userId, id);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
return success(reply, result.data);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate**
|
||||
|
||||
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
|
||||
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Backend tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/__tests__/ai.test.ts`
|
||||
|
||||
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
|
||||
|
||||
```ts
|
||||
import {
|
||||
computeCostUsd,
|
||||
recordUsage,
|
||||
getMonthSpendUsd,
|
||||
getBudgetUsd,
|
||||
assertBudgetAvailable,
|
||||
isConfigured,
|
||||
listConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
appendConversationMessages,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
} from "../services/ai.service";
|
||||
|
||||
const KIND = "test_ai";
|
||||
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||
const CONV_UID_B = 999999992;
|
||||
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||
```
|
||||
|
||||
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
|
||||
|
||||
```ts
|
||||
afterAll(async () => {
|
||||
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||
});
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { title: { contains: HIST_MARKER } },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
|
||||
|
||||
```ts
|
||||
describe("ai.service conversations", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
expect(conv.title).toBe("Nová konverzace");
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||
{ role: "assistant", content: "Nevím, ale…" },
|
||||
]);
|
||||
const list = await listConversations(CONV_UID_A);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
|
||||
"Kolik je hodin v Praze?",
|
||||
"Nevím, ale…",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not overwrite a renamed title on later appends", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||
]);
|
||||
const list = await listConversations(CONV_UID_A);
|
||||
expect(list[0].title).toBe("Moje téma");
|
||||
});
|
||||
|
||||
it("isolates conversations per user (404 across owners)", async () => {
|
||||
const a = await createConversation(CONV_UID_A);
|
||||
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||
const read = await getConversationMessages(CONV_UID_B, a.id);
|
||||
expect("error" in read && read.status).toBe(404);
|
||||
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
|
||||
expect("error" in ren && ren.status).toBe(404);
|
||||
const del = await deleteConversation(CONV_UID_B, a.id);
|
||||
expect("error" in del && del.status).toBe(404);
|
||||
});
|
||||
|
||||
it("cascade-deletes messages with the conversation", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "x" },
|
||||
]);
|
||||
await deleteConversation(CONV_UID_A, conv.id);
|
||||
const remaining = await prisma.ai_chat_messages.count({
|
||||
where: { conversation_id: conv.id },
|
||||
});
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
|
||||
|
||||
```ts
|
||||
it("GET /conversations requires ai.use", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||
const created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: `${HIST_MARKER} t` },
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const id = created.json().data.conversation.id as number;
|
||||
|
||||
const app2 = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||
});
|
||||
expect(app2.statusCode).toBe(200);
|
||||
|
||||
const msgs = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(
|
||||
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||
).toContain("ahoj");
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/admin/ai/conversations/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: `${HIST_MARKER} renamed` },
|
||||
});
|
||||
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
|
||||
|
||||
const del = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/ai/conversations/${id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(del.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("GET messages of a non-existent conversation → 404", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/conversations/999999000/messages",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate**
|
||||
|
||||
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/__tests__/ai.test.ts
|
||||
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend queries + types
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/admin/lib/queries/ai.ts`
|
||||
|
||||
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
|
||||
|
||||
```ts
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const aiConversationsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["ai", "conversations"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ conversations: Conversation[] }>(
|
||||
"/api/admin/ai/conversations",
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
export const aiConversationMessagesOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["ai", "conversation", id, "messages"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ messages: StoredChatMessage[] }>(
|
||||
`/api/admin/ai/conversations/${id}/messages`,
|
||||
),
|
||||
// Re-read the DB on each mount/selection (no stale-cache resurrection).
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Gate**
|
||||
|
||||
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/lib/queries/ai.ts
|
||||
git commit -m "feat(odin): conversation query options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend presentational components
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/components/odin/OdinTabs.tsx`
|
||||
- Create: `src/admin/components/odin/OdinThread.tsx`
|
||||
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
|
||||
- Create: `src/admin/components/odin/OdinComposer.tsx`
|
||||
|
||||
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
|
||||
|
||||
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
|
||||
|
||||
```ts
|
||||
export interface ChatTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
export interface ReviewInvoice {
|
||||
uid: string;
|
||||
supplier_name: string;
|
||||
invoice_number: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
vat_rate: string;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
description: string;
|
||||
file_name: string;
|
||||
file: File;
|
||||
}
|
||||
export type EditableField =
|
||||
| "supplier_name"
|
||||
| "invoice_number"
|
||||
| "amount"
|
||||
| "currency"
|
||||
| "vat_rate"
|
||||
| "issue_date"
|
||||
| "due_date"
|
||||
| "description";
|
||||
export interface StagedFile {
|
||||
id: string;
|
||||
file: File;
|
||||
}
|
||||
```
|
||||
|
||||
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
|
||||
|
||||
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
|
||||
|
||||
```tsx
|
||||
interface OdinTabsProps {
|
||||
conversations: { id: number; title: string }[];
|
||||
activeId: number | null;
|
||||
busy: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onNew: () => void;
|
||||
onRename: (id: number, title: string) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
|
||||
|
||||
- [ ] **Step 3: `OdinThread.tsx`** — props:
|
||||
|
||||
```tsx
|
||||
interface OdinThreadProps {
|
||||
turns: ChatTurn[];
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
threadRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
```
|
||||
|
||||
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
|
||||
|
||||
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
|
||||
|
||||
```tsx
|
||||
interface InvoiceReviewCardProps {
|
||||
inv: ReviewInvoice;
|
||||
busy: boolean;
|
||||
onChange: (uid: string, field: EditableField, value: string) => void;
|
||||
onSave: (inv: ReviewInvoice) => void;
|
||||
onDiscard: (uid: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)`→`onChange(...)`, `saveInvoice`→`onSave`, the discard filter→`onDiscard(inv.uid)`.
|
||||
|
||||
- [ ] **Step 5: `OdinComposer.tsx`** — props:
|
||||
|
||||
```tsx
|
||||
interface OdinComposerProps {
|
||||
input: string;
|
||||
attachments: StagedFile[];
|
||||
busy: boolean;
|
||||
canSubmit: boolean;
|
||||
fileRef: React.RefObject<HTMLInputElement>;
|
||||
onInput: (v: string) => void;
|
||||
onFiles: (files: FileList | null) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
|
||||
|
||||
- [ ] **Step 6: Gate**
|
||||
|
||||
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/components/odin
|
||||
git commit -m "feat(odin): tabs, thread, review-card, composer components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: OdinChat orchestrator + page + delete DashAssistant
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/components/odin/OdinChat.tsx`
|
||||
- Modify: `src/admin/pages/Odin.tsx`
|
||||
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
|
||||
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
|
||||
|
||||
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
|
||||
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
|
||||
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
|
||||
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
|
||||
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
|
||||
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
|
||||
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
|
||||
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
|
||||
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
|
||||
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
|
||||
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
|
||||
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
|
||||
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
|
||||
|
||||
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52–333) — copy them, applying only the adaptations above.
|
||||
|
||||
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
|
||||
|
||||
```tsx
|
||||
import OdinChat from "../components/odin/OdinChat";
|
||||
// …
|
||||
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||
<OdinChat />
|
||||
</Box>;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
|
||||
|
||||
```bash
|
||||
git rm src/admin/components/dashboard/DashAssistant.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate**
|
||||
|
||||
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
|
||||
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (controller)
|
||||
|
||||
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
|
||||
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
|
||||
- Adversarial review pass (workflow) before merge/release.
|
||||
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings
|
||||
|
||||
Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass:
|
||||
the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency
|
||||
finding. The two project-level HIGH _structural refactors_ (service-layer extraction for
|
||||
6 route files; PDF template dedup) are explicitly **out of scope** — they are
|
||||
multi-thousand-line refactors that deserve their own pass.
|
||||
|
||||
Execution: subagent-driven development — one implementer per task (sequential, shared
|
||||
test DB forbids parallel test runs), spec-compliance review then code-quality review
|
||||
after each. No commits until the user asks. Final full gates:
|
||||
`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`.
|
||||
|
||||
---
|
||||
|
||||
## Task H — Dependencies (project-level CRITICAL) — done inline by controller
|
||||
|
||||
- Remove `concurrently` from devDependencies (unused; carries both critical advisories
|
||||
GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1).
|
||||
- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate
|
||||
advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade).
|
||||
- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom`
|
||||
to devDependencies.
|
||||
- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external
|
||||
QR service).
|
||||
- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck.
|
||||
|
||||
## Task A — Attendance create/edit broken since 519edce (top-5 #1)
|
||||
|
||||
Findings (both verified HIGH):
|
||||
|
||||
- `src/admin/pages/AttendanceCreate.tsx:80-99` — handleSubmit posts the raw form whose
|
||||
shape the API does not accept: `arrival_date`/`break_start_date`/`break_start_time`/
|
||||
`break_end_*`/`departure_date` are not in CreateAttendanceSchema (silently stripped —
|
||||
breaks and overnight dates never save); times are never combined with dates (unlike
|
||||
useAttendanceAdmin's combineDatetime), so a validated "HH:MM" would reach
|
||||
createAttendance which does `new Date('08:00')` → Invalid Date → 500; and since commit
|
||||
519edce the always-sent empty-string time fields (`arrival_time: ""`) fail timeString
|
||||
validation, so EVERY submit from this page — including leave records — returns 400.
|
||||
- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) —
|
||||
create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for
|
||||
arrival/departure/break, but since 519edce CreateAttendanceSchema/UpdateAttendanceSchema
|
||||
validate them with `timeString` (HH:MM only) — every create/edit containing a time is
|
||||
rejected 400 "Čas musí být ve formátu HH:MM" (and the service itself expects full
|
||||
datetimes via `new Date(...)`); additionally `break_start`/`break_end` are absent from
|
||||
CreateAttendanceSchema, so breaks are silently stripped on create.
|
||||
|
||||
Direction: first `git show 519edce` to understand the intent; the schema contradicts
|
||||
both client and service, so the fix is to make the schemas validate the combined
|
||||
local-datetime wire format the client sends and the service consumes (lenient helper in
|
||||
`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create
|
||||
schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty
|
||||
optionals. TDD: route-level regression tests (work record with times+breaks, overnight,
|
||||
leave record without times, update) in `src/__tests__/`.
|
||||
|
||||
## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA
|
||||
always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server
|
||||
ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400.
|
||||
The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never
|
||||
called anywhere in the frontend — lockout for any user who lost their authenticator.
|
||||
- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `<img>` from api.qrserver.com
|
||||
is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA
|
||||
enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a
|
||||
third-party URL.) Fix: generate the QR locally with the `qrcode` package
|
||||
(`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed.
|
||||
- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions()
|
||||
which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the
|
||||
per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and
|
||||
wrong button shown.
|
||||
|
||||
Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code
|
||||
goes to backup-verify and completes the login-token flow. If the server endpoint doesn't
|
||||
fit the loginToken flow, extend it minimally (single-use code consumption + logAudit
|
||||
preserved). Server test for backup-verify login path if server is touched.
|
||||
|
||||
## Task C — Invoice/issued-order edit integrity (top-5 #3)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's
|
||||
persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`)
|
||||
though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice
|
||||
silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21%
|
||||
(same falsy-zero at line 689).
|
||||
- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is
|
||||
silently lost: payload includes billing_text but UpdateInvoiceSchema
|
||||
(src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it
|
||||
(updateInvoice supports it via strFields). Add the field to the schema + server test.
|
||||
- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks:
|
||||
`quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate)
|
||||
|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and
|
||||
silently persisted on next save.
|
||||
- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the
|
||||
new status into form.status, so StatusChip, the `editable` flag and the delete-button
|
||||
gate use the stale status after every transition.
|
||||
|
||||
Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x);
|
||||
Number.isFinite(n) ? n : fallback`).
|
||||
|
||||
## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a)
|
||||
|
||||
Findings (all verified HIGH):
|
||||
|
||||
- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated
|
||||
endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit
|
||||
25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no
|
||||
pagination controls.
|
||||
- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page →
|
||||
25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows.
|
||||
- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page;
|
||||
header labels stats "current month" though the query has no month filter.
|
||||
- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but
|
||||
UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT
|
||||
handler never applies it — vehicle change silently dropped. Add to schema
|
||||
(nullableIntIdFromForm) + apply in route + audit + server test.
|
||||
- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the
|
||||
month view and its stat cards.
|
||||
|
||||
Direction for totals: prefer a server-side aggregate (the repo already has per-currency
|
||||
totals endpoints for invoices/orders as the pattern) over fetching all pages client-side;
|
||||
lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces
|
||||
used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must
|
||||
cover the WHOLE month, not one page).
|
||||
|
||||
## Task E — orders.service.ts: PDF blob in every response (top-5 #4b)
|
||||
|
||||
Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683`
|
||||
— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit
|
||||
and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob
|
||||
for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the
|
||||
blob to check status/number. A dedicated /:id/attachment endpoint already exists.
|
||||
Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if
|
||||
the FE needs it — check FE usage), keep the attachment endpoint working, extend
|
||||
`src/__tests__/orders-list.test.ts` to assert attachment_data is absent.
|
||||
|
||||
## Task F — Warehouse: unit enum 400s + dead attachment download
|
||||
|
||||
Findings (both verified HIGH):
|
||||
|
||||
- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the
|
||||
server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value
|
||||
400s create/update. Make it a Select over the enum values (single source: mirror the
|
||||
server enum list).
|
||||
- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets
|
||||
GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST
|
||||
upload + DELETE are registered in warehouse.ts), and a plain <a href> sends no
|
||||
Authorization header anyway. Add the GET download route (requirePermission, correct
|
||||
content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch →
|
||||
object URL, like other binary downloads in the repo). Server test for the new route.
|
||||
|
||||
## Task G — Small fixes
|
||||
|
||||
- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes
|
||||
effect: prints {decision:'block'} JSON to stdout then exits 1; Claude Code only parses
|
||||
stdout JSON on exit 0 and only blocks on exit 2 (reason on stderr). Fix: exit 2 with the
|
||||
reason on stderr.
|
||||
- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits
|
||||
start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating
|
||||
a project without filling BOTH dates always 400s. Send null/omit when empty.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the
|
||||
2 known-mitigated quill lows remain
|
||||
- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten
|
||||
AttendanceCreate payload; 6 regression tests
|
||||
- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via
|
||||
qrcode lib, per-user totp status; 6 tests
|
||||
- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts),
|
||||
billing_text in UpdateInvoiceSchema, status mirror; 2 tests
|
||||
- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats
|
||||
(buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute,
|
||||
print rebuilt (sync window.open, escaped string template); 7 tests
|
||||
- [x] E — orders attachment_data excluded from all non-binary reads (incl.
|
||||
numbering/invoices/orders-pdf callers); 4 tests
|
||||
- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob
|
||||
download; RFC 5987 content-disposition helper (also fixes received-invoices +
|
||||
orders Czech-filename 500); 6 tests
|
||||
- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates
|
||||
→ null
|
||||
- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors ·
|
||||
build OK. Whole-diff 3-lens review: see final report.
|
||||
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal file
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# CSS → MUI Migration — Design Spec
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Approved design, pending implementation plan
|
||||
**Topic:** Replace hand-written CSS with MUI (Material UI), themed to the existing brand, with a modern "Soft-SaaS + dense tables" look, rolled out incrementally.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Migrate the admin frontend off its ~7,100 lines of hand-written CSS onto **MUI (Material UI)** as the styling system, while simultaneously delivering a **modern visual refresh**. Dark/light theming and the existing design tokens are preserved (they already exist and work). The migration is **incremental and always shippable** — never a frozen big-bang.
|
||||
|
||||
This is explicitly a **styling + component-layer migration**. Application behavior, data flow, API contracts, and business logic remain identical (one deliberate exception: modal accessibility — see §5 / §11). Backend code is not touched.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (audit findings, 2026-06-06)
|
||||
|
||||
The premise "we want dark/light mode" is largely **already met**. The audit found:
|
||||
|
||||
- **19 CSS files, ~7,101 lines** under `src/admin/`. Largest: `components.css` (1,224), `plan.css` (1,152), `offers.css` (641), `layout.css` (582), `dashboard.css` (525), `forms.css` (510), `attendance.css` (455), `base.css` (411). Smallest: `responsive.css` (6 lines, effectively an empty placeholder). The token file `variables.css` (164) and `plan.css` are special-cased in §6.
|
||||
- **A complete CSS-variable token system** in `variables.css`: spacing scale, colors, radii, motion durations, fonts, safe-area insets.
|
||||
- **A full dark + light theme** via `[data-theme="dark"]` / `[data-theme="light"]`. The attribute is owned and written by **`src/context/ThemeContext.tsx`** (`useTheme()` / `toggleTheme()`, which calls `document.documentElement.setAttribute("data-theme", …)`). `Sidebar.tsx`, `AdminLayout.tsx`, and `Login.tsx` only **consume** it via `useTheme()`.
|
||||
- **Distinctive fonts already chosen:** Urbanist (headings), Plus Jakarta Sans (body), DM Mono (mono).
|
||||
- **Stack:** React 18.3, Vite 8, TypeScript (strict), TanStack Query v5, **framer-motion** (used broadly — see §9: modals, toasts, the AdminLayout logout transition, dashboard cards, Login), react-datepicker, react-quill-new, Leaflet, @dnd-kit. No Tailwind / MUI / Emotion direct deps; no PostCSS config (PostCSS ships only transitively under Vite).
|
||||
- **Type skew (pre-existing):** `react`/`react-dom` are `^18.3.1` but `@types/react`/`@types/react-dom` are `^19.x`. MUI's component types are sensitive to the `@types/react` major version — see §11 / §14.
|
||||
|
||||
**Implication:** the migration is not about adding theming; it's about replacing the CSS plumbing and modernizing the look while carrying the existing tokens forward.
|
||||
|
||||
---
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| Decision | Choice |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Goal | Both — re-plumb CSS **and** modern visual refresh |
|
||||
| Approach | Component library (not a utility framework) |
|
||||
| Library | **MUI (Material UI)** — target **v7** + **MUI X v8** (pickers), themed to the brand |
|
||||
| Look | **"Soft-SaaS shell (B) + dense data tables (D)"** — airy modern chrome, dense efficient tables |
|
||||
| Theming | Existing CSS-var tokens become the source; one `data-theme` attribute drives both MUI and legacy CSS (mechanism verified against MUI v7 docs — §4.1) |
|
||||
| Roll-out | Incremental, foundation-first; each phase is one or more normal releases |
|
||||
| Pilot page | **Vozidla** (vehicles) — focused CRUD pilot |
|
||||
| Heavy tables | MUI `Table` styled dense, reusing existing `useTableSort` + `usePaginatedQuery` hooks (no DataGrid, no paid license) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
The tokens become the **single shared palette** that feeds both worlds during the migration, so migrated and unmigrated pages stay on the same colors/typography. Structure modernizes per page (see §7 for the token-level vs component-level split).
|
||||
|
||||
```
|
||||
Tokens (colors, fonts, radii, motion, dark/light) ← refreshed once, early
|
||||
│ (identical palette feeds both sides)
|
||||
├───────────────► legacy CSS ── unmigrated pages ┐
|
||||
│ ├─ coexist, same palette
|
||||
└──► MUI theme (createTheme + colorSchemes) ──────────────┘
|
||||
│
|
||||
▼
|
||||
Foundation UI kit (src/admin/ui/*) ──► migrated pages
|
||||
```
|
||||
|
||||
### 4.1 Theming bridge (`src/admin/theme.ts`)
|
||||
|
||||
- One `theme.ts` builds the MUI theme from the tokens:
|
||||
- **Palette:** `primary` = brand red (`#d63031` dark / `#c73030` light), plus `success`/`warning`/`error`/`info` mapped to existing semantic tokens.
|
||||
- **Typography:** Urbanist for `h1–h6`, Plus Jakarta Sans for body, DM Mono for numeric/monospace contexts.
|
||||
- **Shape/spacing:** `borderRadius` and spacing from the token scale.
|
||||
- **`components` overrides:** the Soft-SaaS defaults (rounded cards, pill buttons, soft shadows, tinted chips) baked in so every MUI component is on-brand by default.
|
||||
- **Dark/light keeps the existing toggle (verified against MUI v7 docs).** MUI's CSS theme variables support a custom color-scheme selector that targets the existing attribute exactly:
|
||||
```ts
|
||||
createTheme({
|
||||
cssVariables: { colorSchemeSelector: "[data-theme='%s']" },
|
||||
colorSchemes: { light: true, dark: true },
|
||||
// …palette/typography/components
|
||||
});
|
||||
```
|
||||
This emits `[data-theme="light"]{…}` and `[data-theme="dark"]{…}`, matching the attribute `ThemeContext` already writes. One attribute drives MUI _and_ legacy CSS → no second toggle, 1:1 dark parity.
|
||||
- **One attribute owner.** `ThemeContext` remains the sole writer of `data-theme` on `<html>`. Its `toggleTheme()` must **also call MUI's `setMode()`** so MUI's internal color-scheme state stays in sync with the attribute. Do **not** also enable MUI's `InitColorSchemeScript` attribute-writing (avoid two writers). Wired in Phase 0.
|
||||
|
||||
### 4.2 Coexistence (the safety mechanism)
|
||||
|
||||
- Use MUI's **`ScopedCssBaseline`** around migrated page subtrees only, so MUI's CSS reset applies _inside_ migrated pages without disturbing unmigrated ones still on `base.css`. No global reset until teardown.
|
||||
- The `CssVarsProvider`/`ThemeProvider` must sit **above** every `ScopedCssBaseline`, and `data-theme` must live on a common ancestor (`<html>`, as today) so the `[data-theme='%s']` selector resolves inside scoped subtrees.
|
||||
- Styling engine: MUI default (**Emotion**). SPA only (Vite, no SSR), so no SSR-cache complexity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Foundation UI kit (`src/admin/ui/`)
|
||||
|
||||
**Principle: pages never import `@mui` directly.** A thin wrapper kit bakes in brand defaults and **keeps the current component APIs and Czech labels**, so migrating a page is mostly swapping imports, not rewriting logic, and tuning a default touches one file, not 57.
|
||||
|
||||
| Today | → Foundation kit (`src/admin/ui/`) | MUI base |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| `FormModal` / `ConfirmModal` | `<Modal>` — preserves **FormModal's** full prop set (`submitDisabled`, `subtitle`, `loading`); ConfirmModal needs only `title`/`message`/`type`/`confirmVariant`/`loading` | `Dialog` |
|
||||
| `.admin-table*` + `useTableSort` + `usePaginatedQuery` | **`<DataTable>`** (the dense "D" look) | `Table` |
|
||||
| `AdminDatePicker` (react-datepicker) | `<DatePicker>` — `cs` locale, `dd.MM.yyyy` | MUI X Pickers (free) |
|
||||
| `.admin-btn` | `<Button>` (pill / red-gradient default) | `Button` |
|
||||
| `.admin-form-*` inputs/selects | `<TextField>` / `<Select>` / `<Switch>` | MUI form controls |
|
||||
| `AlertContext` toasts | `<Toast>` — keeps `useAlert()` API | `Snackbar` |
|
||||
| `AdminLayout` sidebar/topbar | `<AppShell>` — the soft-SaaS shell | `Box` / `Drawer` |
|
||||
| KPI tiles | `<KpiCard>` / `<Card>` | `Card` |
|
||||
| Tabs | `<Tabs>` | `Tabs` |
|
||||
|
||||
**Modal behavior change (deliberate a11y gain — not a 1:1 match).** Today's modals use `useModalLock` (a ref-counted body-scroll lock, used in **8 places**, including non-`FormModal` spots like Dashboard, OfferDetail, ReceivedInvoices, WarehouseItemDetail) plus `role="dialog"`, but have **no focus trap, no initial-focus, no return-focus**. MUI `Dialog` adds scroll-lock _and_ a focus trap + return-focus. This is an intentional improvement; it is called out so it isn't a surprise. **Audit all 8 `useModalLock` call sites** — those not backed by `FormModal`/`ConfirmModal` either migrate to `<Modal>` or keep `useModalLock` until their page migrates. Verify stacked/nested modal scroll-lock still works.
|
||||
|
||||
### 5.1 Dense `<DataTable>` (centerpiece)
|
||||
|
||||
MUI `Table` styled to the compact "D" look — uppercase micro headers, **DM Mono right-aligned numerics**, tinted status `Chip`s, subtle row hover — wired to the **existing `useTableSort` (column sort) and `usePaginatedQuery` (list + pagination) hooks**. The current `.admin-table-responsive` horizontal-scroll wrapper carries over as MUI's `TableContainer`.
|
||||
|
||||
- **Numbers stay formatted by the existing utilities.** Numeric/currency cells continue to render through `src/admin/utils/formatters.ts` (`formatCurrency` cs-CZ + `Kč`/`€`, `formatKm`, `czechPlural`). `<DataTable>` only _styles_ (DM Mono, right-align); it never re-formats values.
|
||||
- A mobile card-view variant for the densest tables is a future enhancement (out of scope for the first pass).
|
||||
|
||||
### 5.2 Date pickers
|
||||
|
||||
Swap `AdminDatePicker` → MUI X Date Pickers (free **Community** tier; only Range Pickers are Pro, which we don't need) with the `date-fns` adapter and **`cs` locale**. `AdminDatePicker` is used in **three variants**, all covered by Community:
|
||||
|
||||
| Current (react-datepicker) | → MUI X | Format (date-fns tokens) |
|
||||
| -------------------------- | ---------------------------------- | ------------------------ |
|
||||
| date | `DatePicker` | `dd.MM.yyyy` |
|
||||
| `showMonthYearPicker` | `DatePicker` with month/year views | `MM/yyyy` |
|
||||
| `showTimeSelectOnly` | `TimePicker` | `HH:mm` |
|
||||
|
||||
> **Format-token correction:** date-fns tokens are case-sensitive — `MM` = month, `mm` = minutes, `yyyy` = calendar year (`rrrr` = local-week-year, wrong). The format is **`dd.MM.yyyy`**, not `dd.mm.rrrr`. Apply via `LocalizationProvider` `dateFormats` (e.g. `{ keyboardDate: "dd.MM.yyyy" }`).
|
||||
|
||||
Retires a custom component and the `react-datepicker` dependency; MUI's responsive picker handles touch/mobile.
|
||||
|
||||
---
|
||||
|
||||
## 6. CSS end-state (explicit)
|
||||
|
||||
1. **Gone — ~90% of files (~80% of lines).** All per-feature CSS files deleted as their pages migrate: `buttons`, `forms`, `tables`, `invoices`, `offers`, `attendance`, `dashboard`, `warehouse`, `login`, `settings`, `filemanager`, `pagination`, `datepicker`, `layout`, `base`, `components`, **`responsive`** (the 6-line placeholder). What they expressed becomes the MUI theme + `sx`/`styled`.
|
||||
2. **Tokens move, don't die.** `variables.css` is rewritten into `theme.ts` (TypeScript). With MUI's CSS-variable mode, MUI emits those tokens as CSS custom properties at runtime — so the hand-written `variables.css` file is retired, but the values live on in TS as the single source.
|
||||
3. **Stays — a small residue** for the genuinely-bespoke widgets MUI does not provide:
|
||||
- **Plan/gantt grid** (`plan.css`, ~1,152 lines, slimmed) — kept as a custom component with a trimmed stylesheet (largest survivor; ~16% of today's lines, hence "~80% of lines removed" not 90%).
|
||||
- **Quill rich-text editor** and **Leaflet map** — kept, with small theming CSS.
|
||||
- These surviving stylesheets reference the **MUI-emitted CSS variables**, so they stay in sync with the theme and dark/light.
|
||||
|
||||
**Stated precisely:** custom React components stay (their logic is the value); standard ones are re-skinned via MUI and lose their custom CSS; styling comes from the MUI theme; the only custom CSS remaining is the slim stylesheets for the 3 bespoke widgets, which still read the MUI theme tokens. Literally-zero custom CSS would require rebuilding the plan grid from MUI primitives — explicitly **out of scope** (YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## 7. Aesthetic spec ("Soft-SaaS + dense", validated 2026-06-06)
|
||||
|
||||
The refresh is delivered in **two layers**, which is how it reconciles with the "shared palette" coexistence model:
|
||||
|
||||
- **Token-level (palette) — applies app-wide, early.** These are small refinements to the existing tokens (e.g. light canvas `#f5f4f2 → #f4f3f1`, refined status-chip tints). They are updated once in `variables.css`/`theme.ts` in Phase 0, so **both** legacy and migrated pages share the refreshed palette. This means Phase 0 _does_ shift colors slightly app-wide — it is **not** visually a no-op — but layout/structure is unchanged.
|
||||
- **Component-level (the "modern look") — per page, via MUI.** The distinctive new look (red-gradient pill buttons, 16px soft-shadow rounded cards, the soft-SaaS shell, dense tables) is delivered through MUI component styling as each page migrates. Unmigrated pages keep their current structure on the refreshed palette.
|
||||
|
||||
So §4's "coexist" promise is **same palette/typography, not pixel-identical structure**.
|
||||
|
||||
Concrete values implementers must reproduce (light shown):
|
||||
|
||||
- **Canvas:** `#f4f3f1`. **Surfaces/cards:** `#ffffff`, radius **16px**, soft shadow `0 6px 20px rgba(20,20,40,.06), 0 1px 2px rgba(0,0,0,.03)`.
|
||||
- **Sidebar:** transparent on canvas; active item = white pill with soft shadow + red text/icon.
|
||||
- **Top bar:** Urbanist 800 page title; pill search field; circular icon buttons; gradient avatar; primary action = red-gradient pill button.
|
||||
- **Primary button:** `linear-gradient(135deg,#e23a3a,#c01f1f)`, radius `999px`, shadow `0 5px 14px rgba(214,48,49,.32)`.
|
||||
- **Status chips:** pill, soft tinted — paid `#eafaf0`/`#2b8a3e`, overdue `#fdecec`/`#c92a2a`, draft `#fbf3dd`/`#92760b`.
|
||||
- **Dense table:** padding ~`.5rem 1rem`; headers `.64rem` uppercase, letter-spacing `.05em`, muted; **DM Mono** right-aligned numeric cells; row hover `#faf9f7`; row borders `#f5f3f1`; container is a 16px soft card.
|
||||
- **Fonts:** Urbanist 600–800 (headings), Plus Jakarta Sans 400–700 (body), DM Mono 400–500 (figures).
|
||||
|
||||
**Dark mode.** Several §7 values are new and have no current dark counterpart, so dark is **authored in `theme.ts` during Phase 0** by deriving from these light values + the existing dark surfaces (canvas `#0a0a0a`/`#0f0f0f`, glass borders `rgba(255,255,255,.08)`, dark chip tints), not literally "mirrored."
|
||||
|
||||
**Visual references** (persisted, gitignored): `.superpowers/brainstorm/9839-1780760811/content/` — `aesthetic-directions.html`, `refined-hybrid-faktury.html`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration phasing
|
||||
|
||||
Each phase is **one or more** normal `v1.x` releases (realistic total: ~a dozen releases). Each migrated page deletes its own CSS file.
|
||||
|
||||
- **Phase 0 — Foundation.** Install MUI v7 + Emotion + MUI X v8 Pickers; reconcile `@types/react` with the React 18 runtime (§14); write `theme.ts` from tokens **and apply the token-level palette refresh app-wide** (§7); author the dark palette; wire `ScopedCssBaseline` + the `ThemeContext`↔`setMode()` sync; build the `src/admin/ui/` kit; add the **dev-only** `/ui-kit` showcase route (guarded by `import.meta.env.DEV`, tree-shaken from prod). Layout/structure unchanged; colors shift slightly app-wide. Deletes no per-page CSS yet.
|
||||
- **Phase 1 — `<AppShell>` (chrome).** Migrate sidebar/topbar/theme-toggle to the soft-SaaS shell. Every page _inside the AdminLayout route_ sits in the new frame; unmigrated content still uses its own CSS inside it. Retires most of `layout.css`. **Login is NOT touched here** — it is a sibling route outside `AdminLayout` (`AdminApp.tsx:100`) with its own full-screen layout; see Phase 3.
|
||||
- **Phase 2 — Pilot CRUD page (Vozidla / vehicles).** Full end-to-end: list, dense `<DataTable>`, filters, create/edit `<Modal>`. Battle-tests and tunes the foundation. Deletes that page's CSS.
|
||||
- **Phase 3 — Simple CRUD + Login.** Uživatelé (users), Projects, Settings, and **Login** (migrated standalone — keeps its own full-screen layout, theme toggle, and 2FA two-step + shake/animate-out flow; deletes `login.css`).
|
||||
- **Phase 4 — List-heavy** (Offers, Invoices lists). Hammers `<DataTable>`. Invoice/offer **on-screen detail** (Quill, the detail page) re-themed, not rebuilt. (Generated PDFs are out of scope — §13.)
|
||||
- **Phase 5 — Attendance & Leave/Trips** (grids + forms).
|
||||
- **Phase 6 — Warehouse, then Plan + Dashboard polish.**
|
||||
- **Phase 7 — Teardown.** Delete leftover legacy CSS; retire `variables.css` into `theme.ts`; only the slim bespoke-widget CSS remains.
|
||||
|
||||
At no point is the app frozen or un-shippable.
|
||||
|
||||
---
|
||||
|
||||
## 9. Bespoke components & animation
|
||||
|
||||
**Bespoke components stay custom, re-themed (their logic is the value):**
|
||||
|
||||
- **Plan/gantt grid** (`plan.css`) — keep the grid; repaint from tokens; wrap its controls in the kit.
|
||||
- **Leaflet maps** — keep; align container/control styling to the theme.
|
||||
- **Quill rich-text** (`react-quill-new`) — keep; wrap as `<RichTextEditor>`; theme the toolbar/editor. **Server-side DOMPurify + `cleanQuillHtml` sanitization on the PDF and render paths is unaffected and must remain.**
|
||||
- **@dnd-kit** drag-drop — keep (behavior, not styling).
|
||||
|
||||
**Animation (framer-motion).** framer-motion is used in ~40 files, not just modals. Decision: **keep framer-motion** for the animations MUI does not cover — the **AdminLayout logout transition** (scale + blur + fade), **dashboard card entrances**, **Login** transitions, and plan interactions. MUI's own transitions replace framer-motion **only** inside the components that become MUI (`Dialog` for modals, `Snackbar` for toasts). When migrating `AdminLayout`→`<AppShell>` and the dashboard, **consciously re-create or drop** their framer-motion animations (don't lose them silently). `useReducedMotion` continues to gate bespoke animations; MUI honors `prefers-reduced-motion` via its theme transitions.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing & verification
|
||||
|
||||
This is a UI restyle, so behavior must stay identical (except the deliberate modal a11y gain, §5).
|
||||
|
||||
- **Backend/API tests** (`auth`, `numbering`) unaffected; run each phase to confirm the server is untouched — they stay green.
|
||||
- **Per-page manual verification checklist** (primary gate): every CRUD action, filter, sort, pagination, and modal works as before; **modal focus order / ESC / backdrop-close** behave; **dark and light** both render; **mobile + desktop** (responsive parity, §11); **zero new console errors**; `tsc --noEmit` and `vite build` pass.
|
||||
- **`/ui-kit` dev route:** renders every foundation component in both themes — instant visual QA when tuning the theme; living reference for page migration. Dev-only (stripped from prod).
|
||||
- **Playwright smoke + before/after screenshots:** a thin per-page script loads the page, runs the core flow, captures light+dark screenshots to diff against the pre-migration baseline. (Playwright is already available in the environment.)
|
||||
|
||||
---
|
||||
|
||||
## 11. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Bundle/runtime cost (Emotion) | Measure after Phase 0; tree-shake; acceptable for an internal admin app |
|
||||
| MUI reset clashing with legacy `base.css` mid-migration | `ScopedCssBaseline` around migrated subtrees; provider above it; migrate the shell early |
|
||||
| Dark/light drift between MUI and legacy CSS | One `data-theme` attribute (owned by `ThemeContext`) + `setMode()` sync drives both |
|
||||
| **Modal behavior change** (focus trap, return-focus) | Intentional a11y gain; audit the 8 `useModalLock` sites; add focus/ESC/backdrop to the per-page checklist |
|
||||
| **Responsive parity** — 72 media queries live in the CSS files being deleted | Re-express each page's breakpoints via MUI theme breakpoints / responsive `sx` **when that page migrates** — part of per-page DoD; review high-MQ files (`layout`, `components`, `plan`) explicitly |
|
||||
| **`@types/react` 19 vs React 18.3 skew** | Reconcile types to the React 18 runtime in Phase 0 before building the kit, so the `tsc --noEmit` gate isn't blocked by pre-existing skew |
|
||||
| Czech dates in MUI X Pickers | `date-fns` `cs` locale, **`dd.MM.yyyy`** / `MM/yyyy` / `HH:mm` — verified in Phase 0 |
|
||||
| Czech number/currency formatting | Tables keep `formatters.ts` (`formatCurrency`, `formatKm`); DataTable only styles |
|
||||
| **Per-phase rollback** | Each phase is a self-contained, independently-revertable PR (page migration + its CSS deletion together). Coexistence means reverting one migrated page doesn't affect others. **Phase 1 (AppShell) is the highest-blast-radius phase** — ship behind extra verification; revert = `git revert` the phase PR + redeploy |
|
||||
| Scope creep over a long migration | Strict phase discipline; **restyle only — never refactor logic in the same PR** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Definition of done
|
||||
|
||||
- **Per page:** rendered via the `ui/` kit; its CSS file deleted; all interactions verified; **its media queries re-expressed via MUI breakpoints/`sx`**; modal focus/ESC/backdrop verified; dark + light + mobile + desktop pass; `tsc` + `vite build` green; no new console errors; shipped.
|
||||
- **Overall:** every page migrated; all per-feature CSS deleted (incl. `responsive.css`); `variables.css` retired into `theme.ts`; only the slim bespoke-widget CSS remains; the dev-only `/ui-kit` route reflects the full theme (and stays stripped from prod).
|
||||
|
||||
---
|
||||
|
||||
## 13. Out of scope / deferred
|
||||
|
||||
- **Puppeteer PDF templates** (`src/routes/admin/invoices-pdf.ts`, `offers-pdf.ts`, `orders-pdf.ts`) use their **own self-contained inline `<style>` + print fonts**, independent of the admin stylesheet — **NOT** part of this migration and not aligned to the MUI theme. (DOMPurify + `cleanQuillHtml` sanitization on those paths is unaffected.) Phase 4's "detail re-themed" refers to the **on-screen** detail page only.
|
||||
- Rebuilding the plan/gantt grid from MUI primitives (kept custom-but-themed).
|
||||
- Mobile card-view table variant (future enhancement).
|
||||
- Any backend, API, or business-logic change.
|
||||
- Replacing Leaflet, Quill, or @dnd-kit (kept).
|
||||
|
||||
---
|
||||
|
||||
## 14. Open items to confirm during planning (Phase 0)
|
||||
|
||||
- Confirm **MUI v7** + **MUI X v8** as the pinned versions and re-verify the `cssVariables.colorSchemeSelector` + `colorSchemes` API against the installed version's docs.
|
||||
- **Reconcile `@types/react`** with the React 18.3 runtime (or deliberately validate the chosen MUI version against React-18-runtime-with-React-19-types) before committing the kit.
|
||||
- Verify each of the three MUI X picker variants under the `date-fns` `cs` adapter: `DatePicker` (date), `DatePicker` month/year views (replacing `showMonthYearPicker`), `TimePicker` (replacing `showTimeSelectOnly`).- Audit the 8 `useModalLock` call sites and decide migrate-now vs keep-until-page-migrates for each.
|
||||
- Bundle-size budget/target to check against after Phase 0.
|
||||
|
||||
> **Note:** `CLAUDE.md` references a hook named `useListData` that does not exist (the real hook is `usePaginatedQuery`); this spec uses the correct name. Correcting `CLAUDE.md` is advisable but outside this spec's scope.
|
||||
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
**Phase 1 of 2.** Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the app's first AI capability: an **admin-only chat assistant embedded on the
|
||||
dashboard** where you can talk to Claude and, crucially, **attach received-invoice
|
||||
PDFs that the AI reads and imports into Přijaté faktury** (extract → you confirm →
|
||||
save). All AI usage is bounded by an **admin-editable monthly budget (default
|
||||
$50)** that hard-stops calls when exhausted.
|
||||
|
||||
## Decisions (locked in brainstorming)
|
||||
|
||||
- **Scope (Phase 1):** chat + invoice import + budget cap. **No system-data
|
||||
access** (the AI cannot query your invoices/projects/attendance — that's Phase 2).
|
||||
- **Placement:** a `DashAssistant` widget on the **dashboard, at the top under the
|
||||
welcome text** — not a separate page, not a floating bubble.
|
||||
- **Access:** **admins only**, via a new `ai.use` permission.
|
||||
- **Model:** **Claude Sonnet 4.6** (strong vision, low cost).
|
||||
- **Save flow:** extract → **you confirm/edit → save** (never silent auto-save).
|
||||
- **Budget:** **$50/month**, resets each calendar month, **admin-editable in
|
||||
Settings**; AI calls are **blocked** once the month's spend reaches the budget.
|
||||
- **Chat:** **non-streaming** (request → full reply) and **in-browser history
|
||||
only** (not persisted server-side) for v1 — both are easy later upgrades.
|
||||
|
||||
## Non-goals / out of scope (Phase 1)
|
||||
|
||||
- No system-data query tools / function-calling (Phase 2).
|
||||
- No server-side conversation persistence, no streaming.
|
||||
- No new invoice storage path — reuses `POST /received-invoices` verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Greenfield AI integration; the rest of the app is unaffected and works unchanged
|
||||
if `ANTHROPIC_API_KEY` is absent (the assistant widget simply doesn't render and
|
||||
the AI routes return a clear "not configured" error).
|
||||
|
||||
- **Dependency:** `@anthropic-ai/sdk`.
|
||||
- **Env:** `ANTHROPIC_API_KEY` (required for the feature). Optional
|
||||
`AI_MONTHLY_BUDGET_USD` default seed (the live value lives in the DB, below).
|
||||
- **`src/services/ai.service.ts`** — wraps the SDK, owns the model choice + system
|
||||
prompt + cost accounting. Two operations:
|
||||
- `chat(messages)` → `client.messages.create({ model: "claude-sonnet-4-6", … })`,
|
||||
non-streaming, returns `{ reply: string, usage }`.
|
||||
- `extractInvoice(pdfBuffer, fileName)` → `client.messages.create` with the PDF as
|
||||
a `document` content block + `output_config.format` JSON-schema for the
|
||||
received-invoice fields, returns `{ fields, usage }`.
|
||||
- Both call the spend tracker to record usage **and** are gated by the budget
|
||||
guard (below) BEFORE the API call.
|
||||
- Isolated so tests can stub the Anthropic call (no real API in CI).
|
||||
|
||||
## Spend cap (the $50/month budget)
|
||||
|
||||
- **New table `ai_usage`** (migration): `id`, `user_id?`, `kind` ("chat" |
|
||||
"extract"), `model`, `input_tokens`, `output_tokens`, `cost_usd`
|
||||
`@db.Decimal(10,6)`, `created_at @db.Timestamp(0)`, with an index on
|
||||
`created_at`.
|
||||
- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`.
|
||||
Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd =
|
||||
in*inP + out*outP`.
|
||||
- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on
|
||||
`company_settings` (the existing app-config singleton). Admin-editable in
|
||||
Settings.
|
||||
- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where
|
||||
`created_at >= startOfMonth(now)`; if `>= ai_monthly_budget_usd`, return
|
||||
`{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }`. Called at the top
|
||||
of every AI route BEFORE the Claude call.
|
||||
- **Usage read:** `GET /api/admin/ai/usage` → `{ month_spend_usd, budget_usd,
|
||||
remaining_usd }` for the chat's budget indicator and the Settings page.
|
||||
|
||||
## Backend routes — `src/routes/admin/ai.ts`
|
||||
|
||||
All guarded by `requirePermission("ai.use")`.
|
||||
|
||||
- `POST /api/admin/ai/chat` — body `{ messages: {role, content}[] }`. Budget guard →
|
||||
`ai.service.chat` → log usage → `success(reply: string, remaining_usd)`.
|
||||
- `POST /api/admin/ai/extract-invoices` — multipart PDF files (≤ `MAX_UPLOAD_SIZE`,
|
||||
PDF/image only). Budget guard → `extractInvoice` per file → returns the extracted
|
||||
fields per file (NOT saved) + usage. The files are **not** persisted here.
|
||||
- `GET /api/admin/ai/usage` — current-month spend + budget + remaining.
|
||||
|
||||
If `ANTHROPIC_API_KEY` is unset, the POST routes return
|
||||
`{ error: "AI není nakonfigurováno", status: 503 }`.
|
||||
|
||||
## Invoice import flow (extract → confirm → save)
|
||||
|
||||
1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a
|
||||
text note).
|
||||
2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the
|
||||
extracted fields for each.
|
||||
3. The chat renders an **editable review card per invoice** (dodavatel, číslo
|
||||
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
|
||||
pre-filled with the AI's values, plus a duplicate hint if `invoice_number` +
|
||||
`supplier_name` already exists.
|
||||
4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**.
|
||||
5. The frontend submits **the original File + the confirmed metadata** to the
|
||||
**existing `POST /api/admin/received-invoices`** (multipart) — same NAS storage,
|
||||
same server-side VAT recompute, same audit. The AI never writes invoices
|
||||
directly. (The PDF is uploaded twice — once to extract, once to save — which is
|
||||
fine for invoice-sized files and avoids any server-side temp-file handling.)
|
||||
|
||||
## Frontend
|
||||
|
||||
- **`src/admin/components/dashboard/DashAssistant.tsx`** — the chat widget:
|
||||
message list, input box, attach-file button, and a small budget indicator
|
||||
("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only.
|
||||
Renders the invoice review cards inline (reusing the received-invoice field
|
||||
inputs / kit components).
|
||||
- **`src/admin/pages/Dashboard.tsx`** — render `<DashAssistant />` at the top,
|
||||
directly under the welcome text, **only when** the user is admin / has `ai.use`
|
||||
(and only when AI is configured — a `GET /ai/usage` 503 hides it gracefully).
|
||||
- **`src/admin/lib/queries/ai.ts`** — React Query options/mutations for chat,
|
||||
extract, and usage. On a successful invoice save, invalidate `["received-invoices"]`.
|
||||
- **Settings** — an admin field to set the monthly budget (writes
|
||||
`company_settings.ai_monthly_budget_usd`).
|
||||
|
||||
## Permissions & migration
|
||||
|
||||
One Prisma migration:
|
||||
|
||||
- Create `ai_usage` table.
|
||||
- Add `ai_monthly_budget_usd` to `company_settings` (default 50).
|
||||
- Insert the `ai.use` permission and grant it to the **admin** role (explicit
|
||||
`INSERT`s in `migration.sql`, per the project's migration policy).
|
||||
|
||||
(The admin role bypasses permission checks via `roleName === "admin"`, but the
|
||||
explicit `ai.use` permission lets the access be widened later without code changes
|
||||
and documents intent.)
|
||||
|
||||
## Security / privacy
|
||||
|
||||
- **Phase 1 has no tools**, so the AI cannot take actions on your data — its only
|
||||
effect is the human-confirmed invoice save. A malicious/auto-generated invoice
|
||||
PDF therefore can't trigger anything (the injection-to-action surface arrives in
|
||||
Phase 2 and is designed there).
|
||||
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
|
||||
`.env` (server-side only), never exposed to the browser.
|
||||
- Standard sanitization already applies on the received-invoice render/PDF paths;
|
||||
AI-extracted text flows through the same validated `received_invoices` create.
|
||||
|
||||
## Testing
|
||||
|
||||
Server-side, real test DB, **Anthropic call stubbed** (no real API in CI):
|
||||
|
||||
- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6;
|
||||
monthly sum only counts the current month.
|
||||
- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech
|
||||
message; the guard reads the DB budget value.
|
||||
- **Permissions:** all `/ai/*` routes return `403` without `ai.use`.
|
||||
- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`.
|
||||
- **extract field-mapping:** given a stubbed AI response, the route returns the
|
||||
expected `received_invoices` field shape (and computes nothing that the existing
|
||||
create doesn't already).
|
||||
|
||||
Frontend has no component-test harness → gated by `tsc -b` + `build` + a manual
|
||||
check (the dashboard widget renders for admins, hidden otherwise).
|
||||
|
||||
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ships as **v2.1.0** (notable new capability) via the standard process. **Requires a
|
||||
migration**, so the dev server must be stopped for `prisma migrate dev` (ask first),
|
||||
and the release runs `prisma migrate deploy` on prod. `ANTHROPIC_API_KEY` must be
|
||||
set in the production `.env` before the feature works.
|
||||
|
||||
---
|
||||
|
||||
## Deferred: Phase 2 (system-data access) — captured
|
||||
|
||||
A later, separate spec + cycle. The AI gains **read-only tools** (function calling)
|
||||
to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme
|
||||
fakturovali v květnu?". Key design work for Phase 2:
|
||||
|
||||
- A curated set of **read tools** (search invoices, overdue list, project lookup,
|
||||
attendance summary, …), each **scoped to the calling user's permissions** so the
|
||||
AI never returns data the user can't see.
|
||||
- The **agentic tool-loop** (Claude requests a tool → app executes against Prisma →
|
||||
returns results → Claude continues), with a per-conversation tool-call cap to
|
||||
bound cost (within the same monthly budget).
|
||||
- **Prompt-injection hardening:** because the AI will both read untrusted invoice
|
||||
content _and_ hold tools, tools stay **read-only**, results are treated as data,
|
||||
and any state-changing action remains human-confirmed.
|
||||
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Design — Bulk plan creation (Feature B)
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
**Builds on:** Feature A (multi-record plan cells, max 3 per cell), shipped in v2.0.5. Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Let a planner assign one project/category to **many employees over a date range
|
||||
in a single action**, instead of creating each person's plan record one by one.
|
||||
A "Hromadné přiřazení" button on **/plan-work** opens a modal that mirrors the
|
||||
existing single-record create form, plus an employee checklist and an "include
|
||||
weekends" toggle.
|
||||
|
||||
## Decisions (locked in brainstorming)
|
||||
|
||||
- **Modal mirrors single-create** (`Datum od` + "Více dní" → `Datum do`, Projekt,
|
||||
Kategorie, Text poznámky) **plus** a multi-select employee checklist and a
|
||||
**"Zahrnout víkendy"** checkbox.
|
||||
- **Storage = range entries** (`work_plan_entries`), one per contiguous run of
|
||||
eligible days per employee — the same row shape the single-create form produces.
|
||||
- **Weekends:** default off (Mon–Fri only). Only Sat/Sun are excluded — **no
|
||||
separate holiday handling**.
|
||||
- **Cap:** governed by Feature A's `MAX_RECORDS_PER_CELL = 3`. A day already at
|
||||
the cap for an employee is **skipped** (never overwritten), which also splits
|
||||
that employee's range around the skipped day.
|
||||
- **Past dates blocked** (no `force` exposed in the bulk UI).
|
||||
- **Category** picker defaults to **Práce**; **Project** is optional ("bez
|
||||
projektu"); **note** optional, applied to all created entries.
|
||||
|
||||
## Non-goals / out of scope
|
||||
|
||||
- No DB migration (reuses `work_plan_entries` and Feature A's cap logic).
|
||||
- No overwrite/replace of existing records (purely additive under the cap).
|
||||
- No holiday/`svátky` exclusion (only weekends).
|
||||
- No bulk creation of **overrides** — bulk always creates **entries** (the normal
|
||||
layer); single-day exceptions remain the per-cell day-panel's job.
|
||||
|
||||
---
|
||||
|
||||
## Storage & weekend behaviour
|
||||
|
||||
For each selected employee, the service computes the **eligible days** in
|
||||
`[date_from, date_to]` and groups **contiguous** eligible days into range
|
||||
entries.
|
||||
|
||||
A day `D` is **eligible** for employee `U` when all hold:
|
||||
|
||||
1. `D` is within `[date_from, date_to]` (inclusive).
|
||||
2. `include_weekends` is true, **or** `D` is a weekday (Mon–Fri).
|
||||
3. `U` has fewer than `MAX_RECORDS_PER_CELL` active entries covering `D` (under
|
||||
the cap).
|
||||
|
||||
Contiguous runs of eligible days become one range entry each
|
||||
(`date_from = run start`, `date_to = run end`). Consequences:
|
||||
|
||||
- **Víkendy ON, no cap conflicts:** one continuous range per employee — identical
|
||||
to creating a single multi-day record for everyone.
|
||||
- **Víkendy OFF:** one range per work-week (Mon–Fri chunks), because a weekend day
|
||||
breaks the run.
|
||||
- **A cap-full day** breaks the run too, so the range splits around it (and that
|
||||
`(employee, day)` is counted as skipped).
|
||||
|
||||
In the grid these are ordinary range entries — they show the "součást rozsahu"
|
||||
badge and open the range-vs-this-day chooser on click, exactly like
|
||||
manually-created ranges.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Route — `POST /api/admin/plan/entries/bulk`
|
||||
|
||||
- Guard: `requirePermission("attendance.manage")` (same as single entry create).
|
||||
- Body (validated by a new Zod schema `BulkPlanEntrySchema` in
|
||||
`src/schemas/plan.schema.ts`):
|
||||
```
|
||||
{
|
||||
user_ids: number[] // ≥1
|
||||
date_from: string // YYYY-MM-DD
|
||||
date_to: string // YYYY-MM-DD (≥ date_from)
|
||||
include_weekends: boolean
|
||||
project_id?: number | null
|
||||
category: string // default applied client-side; validated server-side
|
||||
note?: string // default ""
|
||||
}
|
||||
```
|
||||
- On success: one **summary audit** row (`logAudit`, action `create`, entityType
|
||||
`work_plan_entry`, description e.g. `Hromadné přiřazení: <kategorie> <projekt> —
|
||||
N zaměstnanců, <from>–<to> (<created_days> dní)`), then `success(reply,
|
||||
summary, 201, "Hromadné přiřazení dokončeno")`.
|
||||
|
||||
### Service — `bulkCreateEntries(input, actorUserId)` in `src/services/plan.service.ts`
|
||||
|
||||
Up-front validation (fail fast, nothing created):
|
||||
|
||||
- `user_ids.length >= 1` (else `{ error, status: 400 }`).
|
||||
- `date_to >= date_from` (else 400).
|
||||
- `assertNotPastDate(date_from, false)` and `assertNotPastDate(date_to, false)`
|
||||
(bulk blocks past dates; 403).
|
||||
- `assertActiveCategory(category)` (400 if inactive/unknown).
|
||||
|
||||
Then, per employee:
|
||||
|
||||
- Load the employee's active entries overlapping `[date_from, date_to]` once;
|
||||
compute per-day coverage counts.
|
||||
- Walk the days, mark eligible ones (weekday filter + under-cap), group contiguous
|
||||
runs.
|
||||
- For each run, create the range by **reusing `createEntry`**
|
||||
(`{ user_id, date_from: runStart, date_to: runEnd, project_id, category, note }`,
|
||||
`actorUserId`, `force: false`). Reusing `createEntry` keeps cap / past-date /
|
||||
category validation DRY; its cap re-check passes because every day in the run is
|
||||
already under the cap. A `createEntry` that nonetheless returns `{ error }` (rare
|
||||
race) is counted as skipped, not fatal.
|
||||
|
||||
Aggregate and return:
|
||||
|
||||
```
|
||||
{
|
||||
created_entries: number // range rows created
|
||||
created_days: number // (employee, day) records created
|
||||
skipped_days: number // weekday-eligible (employee, day) slots skipped at the cap
|
||||
users: number // employees processed
|
||||
}
|
||||
```
|
||||
|
||||
`skipped_days` counts only days that passed the weekday filter but were at the
|
||||
cap — weekend days (when excluded) are out of scope, not "skipped".
|
||||
|
||||
Best-effort across employees/segments (no cross-entry transaction): up-front
|
||||
validation already rejects bad input, and the cap-skip is by design. Partial
|
||||
results are reported in the summary.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Modal — `src/admin/components/BulkPlanModal.tsx`
|
||||
|
||||
Mirrors the single-create `EditForm` fields + bulk-attendance's employee picker
|
||||
(`BulkAttendanceModal.tsx` is the pattern to follow):
|
||||
|
||||
- `Datum od` (DateField) + "Více dní" CheckboxField → `Datum do` (DateField).
|
||||
- **Zaměstnanci** — scrollable `CheckboxField` list (source: the plan users
|
||||
already loaded for the grid, `/plan/users`), with a "Vybrat vše" / "Odznačit
|
||||
vše" toggle and a "Vybráno X z Y" caption; submit disabled when none selected.
|
||||
- **Zahrnout víkendy** CheckboxField (default off).
|
||||
- **Projekt** Select (optional — "— bez projektu —"), **Kategorie** Select
|
||||
(active categories, default `work`/Práce), **Text poznámky** TextField (optional).
|
||||
- A short helper line, e.g. _"Vytvoří záznamy pro vybrané dny u zvolených
|
||||
zaměstnanců. Dny nad limit 3 záznamů se přeskočí."_
|
||||
|
||||
### Wiring — `src/admin/pages/PlanWork.tsx`
|
||||
|
||||
- A "Hromadné přiřazení" Button in the toolbar (next to "Správa kategorií"),
|
||||
rendered only when `canEdit` (`attendance.manage`).
|
||||
- A `bulkCreate` mutation (React Query) → `POST /api/admin/plan/entries/bulk`;
|
||||
on success show an alert summarising the returned counts (e.g. _"Vytvořeno 18
|
||||
záznamů pro 5 zaměstnanců; 4 dny přeskočeny (limit 3 na den)."_), close the
|
||||
modal, and `invalidate: ["plan"]` so the grid refreshes.
|
||||
|
||||
---
|
||||
|
||||
## Testing (`src/__tests__/plan.test.ts` or a new `bulkPlan.test.ts`)
|
||||
|
||||
Backend service tests against the real test DB:
|
||||
|
||||
- **Weekday filter:** weekends-off over a Mon–Sun span creates only Mon–Fri days
|
||||
(and as the expected number of per-work-week ranges); weekends-on creates one
|
||||
continuous range.
|
||||
- **Segmentation:** a cap-full day in the middle splits the range into two; the
|
||||
skipped day is counted in `skipped_days`.
|
||||
- **Cap skip:** an employee already at 3 entries on some days gets those days
|
||||
skipped; the summary reflects `created_days` vs `skipped_days`.
|
||||
- **Multi-employee aggregation:** `users`, `created_entries`, `created_days` sum
|
||||
correctly across the selected employees.
|
||||
- **Validation:** empty `user_ids` → 400; past `date_from` → 403; inactive
|
||||
category → 400; `date_to < date_from` → 400.
|
||||
|
||||
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ships as **v2.0.6** via the standard process (commit → tag → Gitea → tarball →
|
||||
prod deploy → health check). No migration step. The grid refreshes via
|
||||
`["plan"]` invalidation.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Design — Multi-record plan cells (max 3 per cell)
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
**Feature A of 2.** Feature B (bulk plan creation) is a separate later cycle — see "Deferred: Feature B" at the end.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Today a plan cell (one person, one day) resolves to **exactly one** record: an
|
||||
override hides the planned range, and overlapping range entries collapse to
|
||||
"newest wins". The work plan should instead let a cell hold **up to 3 records**,
|
||||
so a person can have several assignments on the same day (additive) _and_ still
|
||||
get one-day exceptions to a planned range.
|
||||
|
||||
This is the enabling model change. The eventual "bulk assign a project to many
|
||||
employees over a date range" form (Feature B) will create records under this
|
||||
model and respect the 3-per-cell cap.
|
||||
|
||||
## Use cases (both required)
|
||||
|
||||
1. **Additive** — several projects/tasks the same day (e.g. morning project A,
|
||||
afternoon project B). Both records show in the cell.
|
||||
2. **Day exception** — assigned to a multi-day range, but one day differs;
|
||||
replace just that day without splitting the range.
|
||||
|
||||
## Non-goals / out of scope
|
||||
|
||||
- No bulk-create UI (Feature B).
|
||||
- No database migration — the tables already allow multiple rows per
|
||||
(user, day) (the old `work_plan_overrides` uniqueness constraint was dropped in
|
||||
migration `20260605122718_drop_wpo_unique_constraint`).
|
||||
- The max is a fixed constant (3), not user-configurable.
|
||||
|
||||
---
|
||||
|
||||
## Model — "layered" (approved)
|
||||
|
||||
Keep the two existing tables and their meaning; stop collapsing them to one
|
||||
record.
|
||||
|
||||
- **`work_plan_entries` (ranges) = the normal-plan layer.** Multiple active
|
||||
ranges may cover the same day → the **additive** case.
|
||||
- **`work_plan_overrides` (single-day) = the exception layer.** When a day has
|
||||
≥1 active override, the overrides **replace** the range entries for that day
|
||||
(today's "override beats entry" rule, kept) → the **day-exception** case.
|
||||
|
||||
### Resolution rule
|
||||
|
||||
For a given `(user, day)`:
|
||||
|
||||
1. Active overrides for that exact day (`is_deleted = false`). If any →
|
||||
the cell is the overrides, **newest-first, capped at 3**.
|
||||
2. Otherwise → active entries covering that day
|
||||
(`date_from ≤ day ≤ date_to`, `is_deleted = false`), **newest-first, capped at 3**.
|
||||
3. Otherwise → empty.
|
||||
|
||||
The cell is therefore an array of 0–3 `ResolvedCell` items, all from a single
|
||||
layer (overrides **or** entries, never mixed). This preserves the "exception
|
||||
replaces the day" semantic while allowing additive stacking within each layer.
|
||||
|
||||
**Known limitation (accepted):** a range entry and an exception override cannot
|
||||
show together on the same day — an override is all-or-nothing for that day. This
|
||||
matches what "exception" means.
|
||||
|
||||
### Cap (max 3 per cell)
|
||||
|
||||
`MAX_RECORDS_PER_CELL = 3` (single constant in `plan.service.ts`).
|
||||
|
||||
Enforced on create, per layer, per day:
|
||||
|
||||
- **`createOverride`** — reject if the `(user, day)` already has 3 active
|
||||
overrides.
|
||||
- **`createEntry`** — reject if **any** day in the new `[date_from, date_to]`
|
||||
range is already covered by 3 active entries for that user. The error names the
|
||||
first offending date (Czech message). Cap counts entries regardless of whether
|
||||
they are currently hidden by an override (simpler and predictable).
|
||||
|
||||
`createOverride` **changes from replace-semantics to additive-with-cap**: it no
|
||||
longer soft-deletes the existing override for that day. The transaction +
|
||||
row-lock that protected the old "at most one active override" invariant is
|
||||
repurposed to enforce "at most 3" under concurrency. The `replacedData` /
|
||||
`replacedDescription` plumbing (and the route branch that logged the replaced
|
||||
row's soft-delete) is removed.
|
||||
|
||||
---
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `src/services/plan.service.ts`
|
||||
|
||||
- `ResolvedCell` interface unchanged (still one record).
|
||||
- `resolveCell(userId, dateStr)` → returns `ResolvedCell[]` (0–3) using the
|
||||
resolution rule above. Replaces the current single-result + "newest wins"
|
||||
`console.warn`.
|
||||
- `resolveGrid(userIds, from, to)` → `cells[userId][dateStr]` becomes
|
||||
`ResolvedCell[]` (empty array, not `null`, for no records). Same two-query
|
||||
load; per (user, day) build the capped array from the right layer.
|
||||
- `createEntry` → add the per-day entry-cap check before insert.
|
||||
- `createOverride` → drop the soft-delete-replace; add the override-cap check;
|
||||
keep the transaction/lock for race safety; drop `replacedData`.
|
||||
- Add `export const MAX_RECORDS_PER_CELL = 3;`.
|
||||
|
||||
### `src/routes/admin/plan.ts`
|
||||
|
||||
- `POST /plan/overrides` — remove the `result.replacedData` audit branch (no
|
||||
longer produced). Everything else unchanged (cap errors surface via the normal
|
||||
`{ error, status }` path).
|
||||
|
||||
### `src/routes/admin/dashboard.ts`
|
||||
|
||||
- `today_plan` is built from `resolveCell` → now an **array**. Map each record
|
||||
with its category label + colour (the existing enrichment, applied per item).
|
||||
`result.today_plan` becomes `TodayPlan[]` (possibly empty).
|
||||
|
||||
---
|
||||
|
||||
## Frontend changes
|
||||
|
||||
### Types — `src/admin/lib/queries/plan.ts`
|
||||
|
||||
- `GridData.cells: Record<number, Record<string, ResolvedCell[]>>` (was
|
||||
`ResolvedCell | null`).
|
||||
|
||||
### Grid — `src/admin/components/PlanGrid.tsx` + `PlanRangeChips.tsx`
|
||||
|
||||
- A cell renders **up to 3 stacked records**, each a compact row: category
|
||||
colour bar + category label + project (mono). When the cell has **exactly one**
|
||||
record, also show its note (2-line clamp), as today. With 2–3 records, notes
|
||||
are omitted for density.
|
||||
- `--cat-color` is set per record row (not per cell).
|
||||
- `onCellClick(userId, date, cells)` passes the **array**. Empty cell → still a
|
||||
one-click create. Occupied cell → opens the day panel (below).
|
||||
- Past-day / read-only / weekend / today styling unchanged.
|
||||
|
||||
```
|
||||
┌─ Jan N. · Čt 12 ───────┐
|
||||
│▌ PRÁCE · 26710001 │
|
||||
│▌ DOVOLENÁ │
|
||||
│▌ ŠKOLENÍ · 26710044 │
|
||||
└────────────────────────┘ ▌ = category colour bar
|
||||
```
|
||||
|
||||
### Cell editor — `src/admin/components/PlanCellModal.tsx`
|
||||
|
||||
New top-level **day-panel** mode shown when a cell has ≥1 record:
|
||||
|
||||
- Lists the day's records (each: colour bar, category, project, optional note)
|
||||
with **edit (✎)** and **delete (🗑)** per record.
|
||||
- **"+ Přidat záznam"** button, disabled at 3 with a hint
|
||||
("Maximum 3 záznamy na den"). Add targets the **showing layer**: an entry in
|
||||
normal mode, an override in exception mode — so the panel reads simply as
|
||||
"records for this day" and the entry/override distinction stays invisible.
|
||||
- Editing a record reuses the existing `EditForm`. Editing a record that belongs
|
||||
to a **multi-day range** still routes through the existing `day-in-range`
|
||||
chooser ("edit whole range" vs "carve out this day" = create override).
|
||||
- Empty cell skips the panel and opens the create form directly (current
|
||||
behaviour).
|
||||
|
||||
`PlanWork.tsx` wires the new panel: cell-click opens it with the array; the
|
||||
panel's per-record actions reuse the existing entry/override create/update/delete
|
||||
mutations and `invalidate: ["plan"]`.
|
||||
|
||||
### Dashboard — `DashTodayPlan.tsx` + `Dashboard.tsx`
|
||||
|
||||
- `today_plan` prop becomes `TodayPlan[]`. Render up to 3 records stacked
|
||||
(reuse the existing single-record card layout per item). Empty array → the
|
||||
existing "Pro dnešek nemáte naplánováno." state.
|
||||
|
||||
---
|
||||
|
||||
## Testing (`src/__tests__/plan.test.ts`)
|
||||
|
||||
Update existing assertions for the array shape, and add:
|
||||
|
||||
- `resolveCell` returns `[]` for an empty day; one item for a single entry; the
|
||||
override layer (entries hidden) when an override exists.
|
||||
- **Additive:** two entries covering the same day → both returned (newest first).
|
||||
- **Cap display:** 4 overlapping entries on a day → only 3 returned.
|
||||
- **Cap enforcement:** `createEntry` rejects when a day in range already has 3
|
||||
entries (error names the date); `createOverride` rejects at 3 overrides.
|
||||
- **Additive overrides:** `createOverride` no longer soft-deletes the prior
|
||||
override; two overrides on a day coexist.
|
||||
- `resolveGrid` cell arrays match `resolveCell` for the same (user, day).
|
||||
|
||||
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ships as its own release (**v2.0.5**) via the standard process (commit → tag →
|
||||
Gitea → tarball → prod deploy → health check). No migration step.
|
||||
|
||||
---
|
||||
|
||||
## Deferred: Feature B (bulk plan creation) — decisions captured
|
||||
|
||||
A later, separate spec + cycle. Locked decisions from this brainstorming:
|
||||
|
||||
- **Form:** select one project, a date range (from–to), and multiple employees;
|
||||
create the assignment for all of them at once.
|
||||
- **Category:** a category picker in the bulk form, **default Práce**.
|
||||
- **Weekends:** an **"include weekends" checkbox** (off = Mon–Fri only, which
|
||||
requires splitting each person into per-work-week entries; on = one continuous
|
||||
range).
|
||||
- **Conflict handling:** governed by this feature's **max-3-per-cell** rule — for
|
||||
each employee/day, create the record only if the cell is under the cap;
|
||||
otherwise **skip** that day (no error).
|
||||
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Odin — Multi-Conversation AI Chat — Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (design), pending spec review
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the single-thread Odin assistant with a multi-conversation chat: a
|
||||
purpose-built UI on the `/odin` page where the user keeps several named
|
||||
conversations (like topics), switches between them via top tabs, and can
|
||||
rename or delete them. All existing assistant capabilities (chat, PDF invoice
|
||||
extraction → review → save, monthly budget) carry over unchanged.
|
||||
|
||||
## Background (current state)
|
||||
|
||||
- `ai_chat_messages` is a single rolling thread per user (`user_id, role,
|
||||
content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history`
|
||||
read/append/clear that one thread.
|
||||
- The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved
|
||||
off the dashboard in the prior change, branch `feat/odin-page`).
|
||||
- Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`)
|
||||
are unaffected by conversations — they don't persist anything themselves.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Titling:** new conversation auto-titles from its first user message; user
|
||||
can rename.
|
||||
- **Per-conversation actions:** rename + delete.
|
||||
- **Existing history:** migrate the current single thread into one conversation
|
||||
per user ("keep as first conversation"); nothing is lost.
|
||||
- **Layout:** top tabs + chat (not a left sidebar).
|
||||
|
||||
## Data model
|
||||
|
||||
### `ai_conversations` (new)
|
||||
|
||||
| column | type | notes |
|
||||
| ---------- | --------------------- | ----------------------------------- |
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NOT NULL | owner; isolation key |
|
||||
| title | VARCHAR(255) NOT NULL | default "Nová konverzace" |
|
||||
| created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency |
|
||||
|
||||
Index: `idx_ai_conv_user (user_id, id)`.
|
||||
|
||||
### `ai_chat_messages` (modified)
|
||||
|
||||
- Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE
|
||||
CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup).
|
||||
- Replace index `idx_ai_chat_user (user_id, id)` with
|
||||
`idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation).
|
||||
|
||||
### Prisma
|
||||
|
||||
```prisma
|
||||
model ai_conversations {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
title String @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||
messages ai_chat_messages[]
|
||||
|
||||
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||
}
|
||||
|
||||
model ai_chat_messages {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
conversation_id Int
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||
}
|
||||
```
|
||||
|
||||
### Migration (hand-written SQL; one tracked migration)
|
||||
|
||||
1. `CREATE TABLE ai_conversations (...)`.
|
||||
2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`.
|
||||
3. Backfill one conversation per user who has messages:
|
||||
`INSERT INTO ai_conversations (user_id, title, created_at, updated_at)
|
||||
SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;`
|
||||
4. Point messages at it:
|
||||
`UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id
|
||||
SET m.conversation_id = c.id;`
|
||||
5. Drop the old index, enforce NOT NULL, add the FK + new index:
|
||||
`ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user,
|
||||
MODIFY conversation_id INT NOT NULL,
|
||||
ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id)
|
||||
REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
ADD INDEX idx_ai_chat_conv (conversation_id, id);`
|
||||
|
||||
The backfill is a no-op when `ai_chat_messages` is empty (prod today), and
|
||||
preserves the dev thread.
|
||||
|
||||
## Service layer (`ai.service.ts`)
|
||||
|
||||
Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` /
|
||||
`clearChatHistory`) with conversation-scoped ones. Every function takes
|
||||
`userId` and verifies the conversation belongs to the user.
|
||||
|
||||
- `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc
|
||||
(stable tab order).
|
||||
- `createConversation(userId, title?)` → the new conversation (default title
|
||||
"Nová konverzace").
|
||||
- `getConversation(userId, id)` → conversation or null (ownership check helper).
|
||||
- `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest,
|
||||
or `{ error, status }` if not owned/found.
|
||||
- `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**;
|
||||
**auto-title**: if the conversation's title is still the default and this batch
|
||||
has a user message, set `title` = first ~60 chars of that user message.
|
||||
- `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` —
|
||||
ownership-checked; delete cascades messages.
|
||||
|
||||
Auto-title keeps titling server-side and robust (no client title logic).
|
||||
|
||||
## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`)
|
||||
|
||||
| Method | Path | Body / result |
|
||||
| ------ | ----------------------------- | ------------------------------------------ |
|
||||
| GET | `/conversations` | → `{ conversations: [...] }` |
|
||||
| POST | `/conversations` | `{ title? }` → `{ conversation }` |
|
||||
| GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) |
|
||||
| POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) |
|
||||
| PATCH | `/conversations/:id` | `{ title }` (1..255) |
|
||||
| DELETE | `/conversations/:id` | → `{ ok: true }` |
|
||||
|
||||
`:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the
|
||||
service surfaced with `error(reply, ...)`. The old `/history` routes are removed.
|
||||
`/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Queries (`lib/queries/ai.ts`)
|
||||
|
||||
- `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`.
|
||||
- `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`,
|
||||
`GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB,
|
||||
same rationale as today: no stale-cache resurrection).
|
||||
- Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`.
|
||||
|
||||
### Components (`src/admin/components/odin/`)
|
||||
|
||||
- **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy,
|
||||
staged attachments, review list. Loads the conversation list; ensures one
|
||||
exists (creates a default if the user has none); loads the active thread;
|
||||
routes send/extract/save through the active conversation. Full-height flex
|
||||
layout: header → tabs → thread (flex-grow, scroll) → review → composer.
|
||||
- **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) +
|
||||
a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat).
|
||||
Rename uses a small Modal with a text field; delete uses ConfirmDialog.
|
||||
- **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns,
|
||||
user turns right-aligned; the "Pracuji…" indicator; empty-state greeting.
|
||||
(Message text colour set on the `Typography` — GlobalStyles pins `p` to
|
||||
text.secondary.)
|
||||
- **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number,
|
||||
Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit.
|
||||
- **`OdinComposer.tsx`** — staged-attachment chips + attach button + message
|
||||
field (Enter to send) + send button; disabled until the active thread loads.
|
||||
|
||||
`DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`.
|
||||
|
||||
### Data flow
|
||||
|
||||
1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to
|
||||
create a default and select it; else select the first (or last-active).
|
||||
2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)`
|
||||
loads that thread into the displayed turns (seed-once per id; submit marks
|
||||
the thread live so a late load can't clobber — same guard as today).
|
||||
3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant
|
||||
reply → `POST /conversations/:id/messages` persists both → bump usage. On
|
||||
error: roll back, keep input.
|
||||
4. **Send (attachments):** `POST /extract-invoices` → review cards →
|
||||
client-side summary turn → persist the user+summary turns. Save a card →
|
||||
`POST /received-invoices` (gross/VAT-inclusive, unchanged).
|
||||
5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]`
|
||||
(and reset the active id appropriately on delete).
|
||||
|
||||
### Reuse
|
||||
|
||||
All proven logic carries over verbatim: staged-attach (no auto-send), the three
|
||||
bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`,
|
||||
NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user),
|
||||
budget indicator, gross/VAT save. The only structural change is "one thread" →
|
||||
"active conversation thread", plus the tab/CRUD shell.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }`
|
||||
for a conversation the user doesn't own; routes surface it. No cross-user read
|
||||
or write is possible (every query filters by `user_id`).
|
||||
- Persistence is best-effort and logged (never swallowed), as today.
|
||||
- Auto-create-default and create/rename/delete failures surface a Czech toast.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend (Vitest, real DB):
|
||||
|
||||
- Conversation CRUD: create → list → rename → delete (cascade removes messages).
|
||||
- **Isolation:** user A cannot read/append/rename/delete user B's conversation
|
||||
(404), and `listConversations(A)` excludes B's.
|
||||
- Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the
|
||||
title from the first user message and does not overwrite a renamed title.
|
||||
- HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body.
|
||||
|
||||
Frontend: no component tests (consistent with the codebase); gated by
|
||||
`tsc -b` + `npm run build`. Live verification by the user/controller.
|
||||
|
||||
## Out of scope (Phase 2+)
|
||||
|
||||
- Querying system data inside chat (the original deferred Phase-2 scope).
|
||||
- Conversation search, pinning, folders, sharing.
|
||||
- Streaming responses.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Odin → General Assistant (Phase 2+) — Forward-Looking Notes
|
||||
|
||||
**Date:** 2026-06-08 · **Status:** NOT scheduled — design notes only, recorded so Phase 1 choices don't box us in.
|
||||
|
||||
The vision: Odin grows from "chat + invoice import" into a **general assistant** that can **query system data** (invoices, projects, attendance, vehicles…) and **take actions** (create an order, mark paid, draft a quote…). This note assesses whether the Phase-1 architecture supports that, and what to carry forward deliberately.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Phase 1 sets us up well; it does **not** box us in. Two things to carry forward on purpose:
|
||||
|
||||
1. **Structured message storage** (content blocks, not a plain string) — cheap to plan now, costly to retrofit later.
|
||||
2. **The assistant acts strictly as the authenticated user** — every tool runs through the existing permission/ownership layer.
|
||||
|
||||
Everything else (conversations, budget/usage, thin services) is already pointing the right way.
|
||||
|
||||
## What Phase 1 already gets right
|
||||
|
||||
- **Conversations** — multi-turn, persisted, per-user, isolated. Exactly the context substrate an agent needs.
|
||||
- **Budget + usage tracking + `ai.use` gate** — the cost/access spine. Tool loops spend more, so this matters _more_, not less.
|
||||
- **Thin service layer** (`{ data } | { error, status }`, ownership-checked) — ideal to wrap as tools without rewriting business logic.
|
||||
- **The extract → review → save flow** — a perfect template for "propose action → human confirms → execute." The `canSave`/`invoices.create` gate added in Phase 1 is the one-tool version of per-action authorization.
|
||||
|
||||
## The one schema thing to note now
|
||||
|
||||
`ai_chat_messages.content` is a **plain string**. Tool use requires persisting **content blocks** (`text` + `tool_use` + `tool_result`), because the model must see the full tool-call history to continue. When Phase 2 lands, store the **raw content-block array** (JSON column, e.g. `content_json`/`blocks`) and keep a derived plain-text for display/search. Don't change it now — just design Phase 2 persistence as "store the blocks," treating the current string as a lossy projection.
|
||||
|
||||
## The core shift (Phase 2)
|
||||
|
||||
1. **Tool use over the existing services.** Define tools (`list_invoices`, `get_project`, `create_order`, …) whose handlers call the **same service functions the routes use**. The SDK tool-runner loops; we execute. Reuses all validation/business logic and — critically — permissions.
|
||||
2. **Authorization is the hard part.** Every tool MUST run **as the user**, through `requirePermission` / service ownership checks. The assistant must never do what the user can't. It is the user's _delegate_, not a privileged actor.
|
||||
3. **Read vs write.** Read/query tools may run autonomously. Write/destructive tools **propose → confirm → execute** (generalize the invoice review card into a generic "action card"). Aligns with the assistant safety rules (confirm side-effecting actions).
|
||||
4. **Audit.** Every action the assistant takes should `logAudit` like a manual action, attributed to the user with a marker (e.g. `via: "odin"`), for a real trail.
|
||||
5. **Cost.** Tool loops are multi-round-trip → 3–10× the tokens of a chat turn. The $50 cap bites faster; consider per-conversation cost display and per-action budget re-checks (the inter-file budget re-check in `extract-invoices` is the pattern).
|
||||
|
||||
## Data access: tools-over-services, NOT RAG
|
||||
|
||||
Recommendation: **function-calling against the real services**, not embeddings/RAG. The data is structured, live, and permissioned — tools give correct, current, authorized answers. RAG over a snapshot would be stale, would leak across permission boundaries, and would duplicate logic. Reserve embeddings for genuinely unstructured document search if it ever comes up.
|
||||
|
||||
## Other forward notes
|
||||
|
||||
- **Model / effort:** a true agentic assistant wants a stronger model and possibly `effort` tuning; Phase-1 Sonnet single-shot is right for now.
|
||||
- **Streaming:** multi-step tool use feels slow without it — add SSE when Phase 2 lands.
|
||||
- **System prompt:** describe the user's role + available tools + the confirm-before-write rule.
|
||||
- **Managed Agents vs self-hosted loop:** Anthropic's Managed Agents host the loop, but for a self-hosted business app with our own services + permissions, **Claude API + tool use with a self-hosted loop** is the better fit — authorization stays in our stack.
|
||||
|
||||
## What NOT to do prematurely
|
||||
|
||||
- Don't add a tool framework, embeddings, or streaming now — Phase 1 is plain chat + a fixed-prompt extractor, and that's the right scope.
|
||||
- Don't widen `ai.use` to non-admins until the per-action authorization story (point 2) is in place.
|
||||
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Objednávky vydané (Issued Purchase Orders) — Design Spec
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorm) → ready for implementation plan
|
||||
**Author:** brainstorm session
|
||||
|
||||
> **Updated 2026-06-09 (as-built):** Shipped with three deviations from this
|
||||
> brainstorm — **Vydané-first tabs** (Vydané | Přijaté, mirroring Invoices), a
|
||||
> **shared chevron month/year filter** above the tabs that filters both lists,
|
||||
> and a PDF **rebuilt on the shared invoice/order-confirmation template** rather
|
||||
> than the custom layout sketched below. See the affected sections (UI placement,
|
||||
> PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new document type — **objednávky vydané** (purchase orders you issue to a
|
||||
supplier) — alongside the existing customer orders. You author the PO with line
|
||||
items, it gets an auto-generated number, you track its status, and you export it
|
||||
to PDF to send to the supplier. The existing orders module becomes the
|
||||
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
|
||||
page already splits **Vydané | Přijaté**.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
|
||||
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
|
||||
procurement loop (ordered → received → invoiced) is a deliberate later phase.
|
||||
- **No new supplier master.** POs reuse the existing `customers` table as generic
|
||||
business partners.
|
||||
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
|
||||
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
|
||||
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
|
||||
received-document concept and does not apply to documents you create.
|
||||
|
||||
---
|
||||
|
||||
## Decisions log (resolved during brainstorm)
|
||||
|
||||
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
|
||||
quotation → order → project → invoice) stay as the **Přijaté** side, largely
|
||||
unchanged — only surfaced under a new tab. The new **Vydané** side is a
|
||||
brand-new document type. _(Not a `direction` flag on the existing table.)_
|
||||
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
|
||||
used as a generic business partner. No new supplier/vendor master.
|
||||
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
|
||||
received invoices.
|
||||
4. **UI placement.** **Tabs on the Orders page**, mirroring the Invoices page.
|
||||
_(As-built: **Vydané first / Přijaté second** — `Vydané | Přijaté`, tab values
|
||||
`vydane`/`prijate`, persisted in `?tab=` — matching the Invoices page order. A
|
||||
shared **chevron month/year selector** sits above the tabs and filters BOTH
|
||||
lists: Vydané by `order_date`, Přijaté by `created_at`. The pre-existing Přijaté
|
||||
search bug — search term sent but ignored by the backend — was also fixed.)_
|
||||
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
|
||||
mirroring `invoices`/`invoice_items` — _not_ an extension of `orders` and _not_
|
||||
an upload-only record like `received_invoices`.
|
||||
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
|
||||
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
|
||||
use the same per-line-rounded math as `computeInvoiceTotals`.
|
||||
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
|
||||
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
|
||||
assigned **at creation** (even for a `draft`), with release-on-delete.
|
||||
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
|
||||
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
|
||||
permission set across both tabs; **no permission migration needed**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
A self-contained vertical slice following the codebase's established document
|
||||
pattern (the same shape `invoices` uses):
|
||||
|
||||
```
|
||||
prisma/schema.prisma → models issued_orders, issued_order_items + enum
|
||||
src/schemas/issued-orders.schema.ts
|
||||
src/services/issued-orders.service.ts
|
||||
src/services/numbering.service.ts (extend: issued-order number fns)
|
||||
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
|
||||
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
|
||||
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
|
||||
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
|
||||
src/admin/lib/queries/issued-orders.ts
|
||||
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
|
||||
src/admin/AdminApp.tsx (lazy routes for detail/new)
|
||||
src/__tests__/issued-orders.test.ts
|
||||
```
|
||||
|
||||
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
|
||||
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
|
||||
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
|
||||
invalidation, deterministic ordering with an `id` tiebreak, locked
|
||||
read-modify-write for numbering).
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `issued_orders` (parallels `invoices`)
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
|
||||
| `id` | Int PK autoincrement | |
|
||||
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
|
||||
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
|
||||
| `status` | enum `issued_orders_status` | default `draft` |
|
||||
| `currency` | VARCHAR(10) default `"CZK"` | |
|
||||
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
|
||||
| `apply_vat` | Boolean default `true` | |
|
||||
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
|
||||
| `order_date` | Date | date the PO is issued |
|
||||
| `delivery_date` | Date nullable | requested delivery / required-by |
|
||||
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
|
||||
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||
| `issued_by` | VARCHAR(255) nullable | signatory name |
|
||||
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
|
||||
| `internal_notes` | Text nullable | private, **not** on PDF |
|
||||
| `created_at` | DateTime default now | |
|
||||
| `modified_at` | DateTime @updatedAt | |
|
||||
|
||||
**Relations:** `issued_order_items[]`, `customers?`.
|
||||
**Indexes:** `(customer_id)`, `(status, order_date)`.
|
||||
|
||||
### `issued_order_items` (parallels `invoice_items`)
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------------ | ------------------------------------------- | ------------------------------- |
|
||||
| `id` | Int PK | |
|
||||
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
|
||||
| `description` | VARCHAR(500) | main line text |
|
||||
| `item_description` | Text nullable | optional long detail |
|
||||
| `quantity` | Decimal(12,3) default `1.000` | |
|
||||
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
|
||||
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
|
||||
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
|
||||
| `position` | Int default `0` | ordering |
|
||||
|
||||
**Index:** `(issued_order_id)`.
|
||||
|
||||
### `issued_orders_status` enum
|
||||
|
||||
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
|
||||
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
|
||||
|
||||
---
|
||||
|
||||
## Numbering
|
||||
|
||||
A dedicated sequence, exactly like invoices:
|
||||
|
||||
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
|
||||
as the table already enforces). Separate from `"shared"` and `"invoice"`.
|
||||
- New `company_settings` columns:
|
||||
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
|
||||
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
|
||||
`81`; `72` is free and configurable).
|
||||
- New functions in `src/services/numbering.service.ts`, reusing the existing
|
||||
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
|
||||
first-of-year retry):
|
||||
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
|
||||
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
|
||||
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
|
||||
held the current highest number that year (gap prevention), mirroring
|
||||
`releaseInvoiceNumber`.
|
||||
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
|
||||
releases the number per the rule above.
|
||||
|
||||
---
|
||||
|
||||
## Backend API
|
||||
|
||||
### Schema — `src/schemas/issued-orders.schema.ts`
|
||||
|
||||
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
|
||||
built **only** from the shared coercers in `src/schemas/common.ts`:
|
||||
|
||||
- `customer_id` → `nullableIntIdFromForm`
|
||||
- `quantity` → `positiveNumberFromForm`; `unit_price` → `nonNegativeNumberFromForm`
|
||||
- `vat_rate` → `numberInRange(0, 100)`
|
||||
- `order_date` / `delivery_date` → `isoDateString` (lenient)
|
||||
- text fields → `z.string().max(...)`; optional email-like none here
|
||||
- Czech user-facing messages; English identifiers.
|
||||
|
||||
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
|
||||
banned (silent `NaN`).
|
||||
|
||||
### Service — `src/services/issued-orders.service.ts`
|
||||
|
||||
Plain exported async functions returning `{ data }` or `{ error, status }`:
|
||||
|
||||
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
|
||||
`search` (po_number / supplier name); whitelist sort
|
||||
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
|
||||
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
|
||||
- `getIssuedOrder(id)` — full detail with items, supplier name, and
|
||||
`valid_transitions`.
|
||||
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
|
||||
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
|
||||
- `updateIssuedOrder(id, body)` — validates the status transition against
|
||||
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
|
||||
is editable (`draft`/`sent`); blocks any `po_number` change.
|
||||
- `deleteIssuedOrder(id)` — delete (items cascade) then
|
||||
`releaseIssuedOrderNumber(year, po_number)`.
|
||||
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
|
||||
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
|
||||
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
|
||||
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
|
||||
Falls back line `vat_rate` → doc `vat_rate` → 21.
|
||||
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
|
||||
|
||||
`VALID_TRANSITIONS`:
|
||||
|
||||
```
|
||||
draft → [sent, cancelled]
|
||||
sent → [confirmed, cancelled]
|
||||
confirmed → [completed, cancelled]
|
||||
completed → [] // terminal
|
||||
cancelled → [] // terminal
|
||||
```
|
||||
|
||||
### Routes — `src/routes/admin/issued-orders.ts`
|
||||
|
||||
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
|
||||
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
|
||||
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
|
||||
with `oldValues`/`newValues`). Adding this entity type requires also registering
|
||||
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
|
||||
`EntityType` value), per the single-source-of-truth convention.
|
||||
|
||||
| Method · Path | Guard | Purpose |
|
||||
| ------------------------------------------ | --------------- | -------------------------- |
|
||||
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
|
||||
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
|
||||
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
|
||||
| POST `/api/admin/issued-orders` | `orders.create` | create |
|
||||
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
|
||||
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
|
||||
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
|
||||
|
||||
---
|
||||
|
||||
## PDF export
|
||||
|
||||
> **As-built (2026-06-09):** rather than the custom/compact layout this section
|
||||
> originally sketched, the issued-order PDF was **rebuilt on the shared
|
||||
> invoice / order-confirmation red-accent (#de3a3a) template** (`src/routes/admin/issued-orders-pdf.ts`),
|
||||
> titled **OBJEDNÁVKA**, for visual consistency with the rest of the document
|
||||
> suite. Because a PO is a buying document, the direction is **flipped vs an
|
||||
> invoice**: the selected **customer record renders as "Dodavatel" (supplier)**
|
||||
> and **your company as "Odběratel" (buyer)**. It is generated **on demand** and
|
||||
> streamed as `application/pdf` — **no NAS persistence**. Sanitization,
|
||||
> on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
|
||||
|
||||
New route + template `src/routes/admin/issued-orders-pdf.ts`, reusing
|
||||
`htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
|
||||
|
||||
- **Dodavatel (supplier) block** = the selected `customers` record (name,
|
||||
IČO/`company_id`, DIČ/`vat_id`, address). **Odběratel (buyer) block** = your
|
||||
company (`company_settings`: name, IČO, DIČ, address, logo as base64).
|
||||
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
|
||||
- `cs` / `en` strings selected by `language`. Czech date/currency via the
|
||||
existing formatters.
|
||||
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
|
||||
used by invoices/orders before going into the template (mandatory for any new
|
||||
rich-text-on-PDF field).
|
||||
- **v1 behavior:** generate on demand and stream `application/pdf` with
|
||||
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
|
||||
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
|
||||
isolated follow-up.)
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
- **`src/admin/pages/Orders.tsx` — add `Vydané | Přijaté` tabs** using the same
|
||||
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
|
||||
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
|
||||
and permission check preserved unchanged; only presentation is reorganized. The
|
||||
**Vydané** tab renders the new list.
|
||||
_(As-built: `Orders.tsx` became a **parent shell** that owns the `PageHeader` +
|
||||
the shared month/year selector + the tabs; `IssuedOrders.tsx` (Vydané) and
|
||||
`OrdersReceived.tsx` (Přijaté) are **content-only children** receiving `month`/`year`
|
||||
props. `OrdersReceived`'s create modal is **hoisted** to the shell via
|
||||
`createOpen`/`setCreateOpen`, the ReceivedInvoices pattern. **No KPI/stat cards**
|
||||
on the Orders landing — deliberate, since orders have no paid/overdue semantics.)_
|
||||
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
|
||||
status chip (color per status), `order_date`, total (currency), actions (open,
|
||||
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
|
||||
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
|
||||
vydanou" button gated on `orders.create`.
|
||||
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
|
||||
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
|
||||
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
|
||||
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
|
||||
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
|
||||
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
|
||||
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
|
||||
imports come from the `ui/` kit.
|
||||
- **Queries** `src/admin/lib/queries/issued-orders.ts` — `issuedOrderListOptions`,
|
||||
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
|
||||
invalidate the **broad `["issued-orders"]`** domain key.
|
||||
- **Rules of Hooks:** all hooks run before any early permission `return`
|
||||
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
|
||||
`react-hooks/rules-of-hooks` as an error.
|
||||
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Reuse the existing `orders.*` set for the Vydané tab:
|
||||
|
||||
- `orders.view` — list + detail + PDF view
|
||||
- `orders.create` — create + next-number preview
|
||||
- `orders.edit` — update
|
||||
- `orders.delete` — delete
|
||||
- `orders.export` — PDF export (already seeded)
|
||||
|
||||
This mirrors invoices (issued + received share one `invoices.*` set) and needs
|
||||
**no permission migration**. _(Future option, out of scope for v1: a dedicated
|
||||
`purchase-orders._` set via a seed + migration if purchasing must be locked down
|
||||
separately from sales orders.)\*
|
||||
|
||||
---
|
||||
|
||||
## Status lifecycle
|
||||
|
||||
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
|
||||
|
||||
- **Editable** (header + items): `draft`, `sent`.
|
||||
- **Read-only except a permitted status change**: `confirmed`, `completed`,
|
||||
`cancelled`.
|
||||
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
|
||||
page only renders buttons for valid next states.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
|
||||
`buildApp()` (no Prisma mocks):
|
||||
|
||||
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
|
||||
generated number matches the configured pattern.
|
||||
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
|
||||
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
|
||||
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
|
||||
- **Status transitions:** every valid transition succeeds; invalid transitions
|
||||
return the proper error; items become read-only after `confirmed`.
|
||||
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
|
||||
permission (not bare auth).
|
||||
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
A single `prisma migrate dev --name issued_orders`:
|
||||
|
||||
1. Creates `issued_orders` and `issued_order_items` tables + the
|
||||
`issued_orders_status` enum.
|
||||
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
|
||||
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
|
||||
|
||||
Per project rules: **ask the user to stop the dev server first**; commit both
|
||||
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
|
||||
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
|
||||
|
||||
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
|
||||
`npx vitest run`, `npm run lint` (0 errors).
|
||||
|
||||
---
|
||||
|
||||
## Future phases (explicitly out of scope for v1)
|
||||
|
||||
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
|
||||
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
|
||||
Odin pre-filling a received invoice from a PO.
|
||||
- **NAS persistence** of the generated PO PDF (`?save=1`).
|
||||
- **Dedicated `purchase-orders.*` permission set.**
|
||||
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.
|
||||
78
eslint.config.mjs
Normal file
78
eslint.config.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"dist-client/**",
|
||||
"node_modules/**",
|
||||
"prisma/migrations/**",
|
||||
"src/admin/bones/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"*.config.ts",
|
||||
".claude/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
// Server + shared code
|
||||
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node, ...globals.browser },
|
||||
},
|
||||
rules: {
|
||||
// TS already resolves identifiers — `no-undef` only false-positives on
|
||||
// global types/ambient names in .ts files.
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "warn",
|
||||
"no-empty": ["warn", { allowEmptyCatch: false }],
|
||||
// Intentional control-character regexes (NUL/path-traversal guards,
|
||||
// combining-marks stripping) — not a defect here.
|
||||
"no-control-regex": "off",
|
||||
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
|
||||
"no-useless-escape": "warn",
|
||||
"no-useless-assignment": "warn",
|
||||
"preserve-caught-error": "warn",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Frontend React code
|
||||
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
|
||||
files: ["scripts/**/*.{js,mjs,cjs}"],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
rules: { "no-undef": "off" },
|
||||
},
|
||||
{
|
||||
// Tests
|
||||
files: ["src/__tests__/**/*.ts"],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
@@ -13,7 +13,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
|
||||
3716
package-lock.json
generated
3716
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.9.2",
|
||||
"version": "2.4.7",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -14,75 +14,88 @@
|
||||
"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",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
"seed": "tsx prisma/seed.ts",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@mui/material": "^7.3.11",
|
||||
"@mui/x-date-pickers": "^8.29.0",
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"file-type": "^16.5.4",
|
||||
"file-type": "^22.0.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"mariadb": "^3.5.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"otpauth": "^9.5.0",
|
||||
"prisma": "^6.19.2",
|
||||
"prisma": "^7.8.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mysql": "^2.15.27",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "^1.19.13"
|
||||
}
|
||||
}
|
||||
|
||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
// Prisma 7 moved the datasource connection URL and seed config out of
|
||||
// schema.prisma / package.json into this file. `import "dotenv/config"` is
|
||||
// required because Prisma 7 no longer auto-loads .env. The runtime database
|
||||
// connection itself is made via the driver adapter in src/config/database.ts.
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
migrations: {
|
||||
seed: "tsx prisma/seed.ts",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||
|
||||
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||
|
||||
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN ('projects.create');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
|
||||
|
||||
CREATE TABLE `ai_usage` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NULL,
|
||||
`kind` VARCHAR(20) NOT NULL,
|
||||
`model` VARCHAR(50) NOT NULL,
|
||||
`input_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||
`output_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_ai_usage_created` (`created_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `company_settings`
|
||||
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
|
||||
|
||||
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
|
||||
-- the warehouse-permissions migration.
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
|
||||
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name = 'ai.use';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AI assistant (Phase 1): per-user chat history (server-side, permanent).
|
||||
|
||||
CREATE TABLE `ai_chat_messages` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_ai_chat_user` (`user_id`, `id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||
|
||||
CREATE TABLE `ai_conversations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `ai_chat_messages`
|
||||
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||
|
||||
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||
FROM `ai_chat_messages`
|
||||
GROUP BY `user_id`;
|
||||
|
||||
UPDATE `ai_chat_messages` m
|
||||
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||
SET m.`conversation_id` = c.`id`;
|
||||
|
||||
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||
ALTER TABLE `ai_chat_messages`
|
||||
DROP INDEX `idx_ai_chat_user`,
|
||||
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Audit schema hardening (from the 2026-06-09 codebase audit).
|
||||
--
|
||||
-- ⚠️ PENDING — NOT YET APPLIED. Review, then apply with the dev server stopped:
|
||||
-- npx prisma migrate deploy (prod / already-tracked)
|
||||
-- -- or, in dev, `npx prisma migrate dev` will pick it up as pending.
|
||||
--
|
||||
-- What this does:
|
||||
-- • Warehouse line→header relations now CASCADE on delete (deleting a
|
||||
-- receipt/issue/inventory session removes its lines/attachments).
|
||||
-- • Warehouse line→master-data (item/batch/location) and the financial FKs
|
||||
-- (invoices/orders/projects/quotations → customer/order/quotation) are now
|
||||
-- explicit ON DELETE RESTRICT — preventing deletion of a referenced master
|
||||
-- record / orphaning of a financial record. (Some of these were previously
|
||||
-- the optional-relation default SET NULL; RESTRICT is the safer intent.)
|
||||
-- • UNIQUE on warehouse document numbers (receipt_number/issue_number/
|
||||
-- session_number) — like every other document-number column.
|
||||
-- • New indexes on hot FK filters (sklad_issues/receipts, bank_accounts).
|
||||
--
|
||||
-- ⚠️ BEFORE APPLYING ON PRODUCTION, verify existing data won't violate the new
|
||||
-- constraints: no DUPLICATE non-null receipt/issue/session numbers (else the
|
||||
-- UNIQUE index fails), and no rows that would break the RESTRICT FKs. Test on
|
||||
-- a copy first.
|
||||
--
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_2`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_3`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `quotations` DROP FOREIGN KEY `quotations_ibfk_1`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_receipt_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_location_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_receipt_attachments` DROP FOREIGN KEY `sklad_receipt_attachments_receipt_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_issue_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_location_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_session_id_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_location_id_fkey`;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `idx_bank_accounts_is_default` ON `bank_accounts`(`is_default`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_receipts_receipt_number_key` ON `sklad_receipts`(`receipt_number`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_receipts_supplier_id` ON `sklad_receipts`(`supplier_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_receipts_received_by` ON `sklad_receipts`(`received_by`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_issues_issue_number_key` ON `sklad_issues`(`issue_number`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_issues_project_id` ON `sklad_issues`(`project_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `sklad_issues_issued_by` ON `sklad_issues`(`issued_by`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `sklad_inventory_sessions_session_number_key` ON `sklad_inventory_sessions`(`session_number`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_3` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `quotations` ADD CONSTRAINT `quotations_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_orders` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`po_number` VARCHAR(50) NULL,
|
||||
`customer_id` INTEGER NULL,
|
||||
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
|
||||
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||
`apply_vat` BOOLEAN NULL DEFAULT true,
|
||||
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
|
||||
`order_date` DATE NULL,
|
||||
`delivery_date` DATE NULL,
|
||||
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||
`delivery_terms` VARCHAR(500) NULL,
|
||||
`payment_terms` VARCHAR(500) NULL,
|
||||
`issued_by` VARCHAR(255) NULL,
|
||||
`notes` TEXT NULL,
|
||||
`internal_notes` TEXT NULL,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
|
||||
INDEX `issued_orders_customer_id`(`customer_id`),
|
||||
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_order_items` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issued_order_id` INTEGER NOT NULL,
|
||||
`description` VARCHAR(500) NULL,
|
||||
`item_description` TEXT NULL,
|
||||
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
|
||||
`unit` VARCHAR(20) NULL,
|
||||
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
|
||||
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
|
||||
INDEX `issued_order_id`(`issued_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `company_settings` ADD COLUMN `project_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `project_type_code` VARCHAR(10) NULL;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Drop the three DEAD document "export" permissions and make the 12 surviving
|
||||
-- document permissions reproducible on a fresh production database.
|
||||
--
|
||||
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
|
||||
-- backend — every PDF/export route is guarded by `*.view`. They only ever
|
||||
-- toggled frontend buttons and showed up as dead checkboxes in role management.
|
||||
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
|
||||
-- enforced on >=1 route and must survive.
|
||||
--
|
||||
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
|
||||
-- fresh production DB never had ANY of them. This migration both removes the 3
|
||||
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
|
||||
-- prisma/seed.ts column set exactly (name, display_name, module, description,
|
||||
-- created_at; role_permissions has only the composite PK).
|
||||
--
|
||||
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
|
||||
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
|
||||
-- unique `permissions.name` key and on the `role_permissions` composite PK.
|
||||
|
||||
-- 1. Remove the dead export role assignments first (FK-safe: children before
|
||||
-- parents). No-op when the permissions/assignments don't exist.
|
||||
DELETE FROM `role_permissions`
|
||||
WHERE `permission_id` IN (
|
||||
SELECT `id` FROM `permissions`
|
||||
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
|
||||
);
|
||||
|
||||
-- 2. Remove the dead export permissions themselves.
|
||||
DELETE FROM `permissions`
|
||||
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
|
||||
|
||||
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
|
||||
-- IGNORE skips any that already exist (unique `name`). display_name /
|
||||
-- description / module match prisma/seed.ts verbatim.
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
|
||||
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
|
||||
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
|
||||
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
|
||||
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
|
||||
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
|
||||
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
|
||||
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
|
||||
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
|
||||
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
|
||||
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
|
||||
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
|
||||
|
||||
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
|
||||
-- PK (role_id, permission_id) makes this idempotent and preserves any other
|
||||
-- role assignments that already exist.
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN (
|
||||
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
|
||||
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
|
||||
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
|
||||
ADD COLUMN `supplier_id` INTEGER NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||
DROP COLUMN `vat_rate`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
|
||||
ADD COLUMN `locked_by` INTEGER NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `issued_order_sections` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issued_order_id` INTEGER NOT NULL,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
`title` VARCHAR(500) NULL,
|
||||
`title_cz` VARCHAR(500) NULL,
|
||||
`content` TEXT NULL,
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `issued_order_sections_order_id`(`issued_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||
DROP COLUMN `issued_by`,
|
||||
DROP COLUMN `notes`,
|
||||
DROP COLUMN `payment_terms`;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
|
||||
-- the printed notes block; notes become internal-only. Existing printed notes
|
||||
-- are preserved by merging them into internal_notes before the column drop
|
||||
-- (both are Quill HTML — concatenation is valid markup).
|
||||
|
||||
-- Preserve data: move printed notes into internal_notes
|
||||
UPDATE `invoices`
|
||||
SET `internal_notes` = CASE
|
||||
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
|
||||
ELSE CONCAT(`internal_notes`, `notes`)
|
||||
END
|
||||
WHERE `notes` IS NOT NULL AND `notes` <> '';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoices` DROP COLUMN `notes`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `invoice_sections` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`invoice_id` INTEGER NOT NULL,
|
||||
`position` INTEGER NULL DEFAULT 0,
|
||||
`title` VARCHAR(500) NULL,
|
||||
`title_cz` VARCHAR(500) NULL,
|
||||
`content` TEXT NULL,
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `invoice_sections_invoice_id`(`invoice_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Suppliers get a structured address (street/city/postal_code/country) like
|
||||
-- customers; the single free-text `address` blob is dropped. Existing data is
|
||||
-- split best-effort: newlines normalized to ", ", first comma segment ->
|
||||
-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city.
|
||||
-- Unparseable one-segment addresses keep their full text in `street`.
|
||||
|
||||
-- AlterTable: add the structured columns
|
||||
ALTER TABLE `sklad_suppliers`
|
||||
ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`,
|
||||
ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`,
|
||||
ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`,
|
||||
ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`;
|
||||
|
||||
-- Preserve data: best-effort split of the legacy free-text address
|
||||
UPDATE `sklad_suppliers`
|
||||
SET
|
||||
`street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''),
|
||||
`postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'),
|
||||
`city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE(
|
||||
SUBSTRING(
|
||||
REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '),
|
||||
CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2
|
||||
),
|
||||
'[0-9]{3}[ ]?[0-9]{2}', ''))), '')
|
||||
WHERE `address` IS NOT NULL AND `address` <> '';
|
||||
|
||||
-- AlterTable: drop the legacy blob
|
||||
ALTER TABLE `sklad_suppliers` DROP COLUMN `address`;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Suppliers become a full mirror of the customers model: dedicated
|
||||
-- contact_person/email/phone/notes columns are replaced by the same
|
||||
-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}],
|
||||
-- "field_order":[]}). Existing contact data is preserved as custom fields.
|
||||
|
||||
-- AlterTable: add the custom_fields blob
|
||||
ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`;
|
||||
|
||||
-- Seed an empty container for rows that carry any contact data
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY())
|
||||
WHERE COALESCE(`contact_person`, '') <> ''
|
||||
OR COALESCE(`email`, '') <> ''
|
||||
OR COALESCE(`phone`, '') <> ''
|
||||
OR COALESCE(`notes`, '') <> '';
|
||||
|
||||
-- Preserve data: each legacy column becomes one labeled custom field
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`contact_person`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`email`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`phone`, '') <> '';
|
||||
|
||||
UPDATE `sklad_suppliers`
|
||||
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||
JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE))
|
||||
WHERE COALESCE(`notes`, '') <> '';
|
||||
|
||||
-- AlterTable: drop the dedicated columns
|
||||
ALTER TABLE `sklad_suppliers`
|
||||
DROP COLUMN `contact_person`,
|
||||
DROP COLUMN `email`,
|
||||
DROP COLUMN `phone`,
|
||||
DROP COLUMN `notes`;
|
||||
@@ -4,33 +4,32 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -68,7 +67,6 @@ model audit_logs {
|
||||
session_id String? @db.VarChar(128)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
|
||||
@@index([created_at], map: "idx_audit_log_created")
|
||||
@@index([action], map: "idx_audit_logs_action")
|
||||
@@index([created_at], map: "idx_audit_logs_created")
|
||||
@@index([entity_type, entity_id, created_at], map: "idx_audit_logs_entity")
|
||||
@@ -76,6 +74,42 @@ model audit_logs {
|
||||
@@fulltext([description], map: "idx_audit_search")
|
||||
}
|
||||
|
||||
model ai_usage {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int?
|
||||
kind String @db.VarChar(20)
|
||||
model String @db.VarChar(50)
|
||||
input_tokens Int @default(0)
|
||||
output_tokens Int @default(0)
|
||||
cost_usd Decimal @default(0) @db.Decimal(10, 6)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
|
||||
@@index([created_at], map: "idx_ai_usage_created")
|
||||
}
|
||||
|
||||
model ai_conversations {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
title String @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||
messages ai_chat_messages[]
|
||||
|
||||
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||
}
|
||||
|
||||
model ai_chat_messages {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
conversation_id Int
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||
}
|
||||
|
||||
model bank_accounts {
|
||||
id Int @id @default(autoincrement())
|
||||
account_name String? @db.VarChar(255)
|
||||
@@ -88,52 +122,59 @@ model bank_accounts {
|
||||
position Int? @default(0)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
@@index([is_default], map: "idx_bank_accounts_is_default")
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
model customers {
|
||||
@@ -157,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], onUpdate: NoAction, map: "invoices_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], 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")
|
||||
@@ -208,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)
|
||||
@@ -306,8 +360,6 @@ model orders {
|
||||
status String? @default("prijata") @db.VarChar(30)
|
||||
currency String? @default("CZK") @db.VarChar(10)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||
scope_title String? @db.VarChar(500)
|
||||
scope_description String? @db.Text
|
||||
@@ -317,14 +369,76 @@ model orders {
|
||||
invoices invoices[]
|
||||
order_items order_items[]
|
||||
order_sections order_sections[]
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_2")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_1")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_2")
|
||||
projects projects[]
|
||||
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@index([quotation_id], map: "quotation_id")
|
||||
}
|
||||
|
||||
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[]
|
||||
issued_order_sections issued_order_sections[]
|
||||
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
||||
|
||||
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||
}
|
||||
|
||||
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
|
||||
// rendered on their own PDF page. Kept as a separate table (not shared) so the
|
||||
// FK cascade and per-document ordering stay simple.
|
||||
model issued_order_sections {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
position Int? @default(0)
|
||||
title String? @db.VarChar(500)
|
||||
title_cz String? @db.VarChar(500)
|
||||
content String? @db.Text
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
|
||||
|
||||
@@index([issued_order_id], map: "issued_order_sections_order_id")
|
||||
}
|
||||
|
||||
model issued_order_items {
|
||||
id Int @id @default(autoincrement())
|
||||
issued_order_id Int
|
||||
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)
|
||||
position Int? @default(0)
|
||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
||||
|
||||
@@index([issued_order_id], map: "issued_order_id")
|
||||
}
|
||||
|
||||
enum issued_orders_status {
|
||||
draft
|
||||
sent
|
||||
confirmed
|
||||
completed
|
||||
cancelled
|
||||
}
|
||||
|
||||
model permissions {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique(map: "name") @db.VarChar(50)
|
||||
@@ -348,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], onUpdate: NoAction, map: "projects_ibfk_1")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
|
||||
orders orders? @relation(fields: [order_id], references: [id], 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")
|
||||
@@ -395,28 +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)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
order_id Int?
|
||||
status String @default("active") @db.VarChar(20)
|
||||
scope_title String? @db.VarChar(500)
|
||||
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], 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")
|
||||
@@ -490,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")
|
||||
}
|
||||
@@ -566,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")
|
||||
@@ -692,20 +804,21 @@ model sklad_categories {
|
||||
}
|
||||
|
||||
model sklad_suppliers {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @db.VarChar(255)
|
||||
ico String? @db.VarChar(20)
|
||||
dic String? @db.VarChar(20)
|
||||
contact_person String? @db.VarChar(255)
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
address String? @db.Text
|
||||
notes String? @db.Text
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
id Int @id @default(autoincrement())
|
||||
name String @db.VarChar(255)
|
||||
ico String? @db.VarChar(20)
|
||||
dic String? @db.VarChar(20)
|
||||
street String? @db.VarChar(255)
|
||||
city String? @db.VarChar(255)
|
||||
postal_code String? @db.VarChar(20)
|
||||
country String? @db.VarChar(100)
|
||||
custom_fields String? @db.LongText
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
receipts sklad_receipts[]
|
||||
receipts sklad_receipts[]
|
||||
issued_orders issued_orders[]
|
||||
|
||||
@@map("sklad_suppliers")
|
||||
}
|
||||
@@ -719,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[]
|
||||
@@ -728,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])
|
||||
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
|
||||
batches sklad_batches[]
|
||||
item_locations sklad_item_locations[]
|
||||
receipt_lines sklad_receipt_lines[]
|
||||
@@ -755,170 +868,174 @@ 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])
|
||||
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
|
||||
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])
|
||||
location sklad_locations @relation(fields: [location_id], references: [id])
|
||||
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? @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])
|
||||
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
|
||||
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")
|
||||
@@map("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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
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])
|
||||
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? @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])
|
||||
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
|
||||
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")
|
||||
@@map("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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
batch sklad_batches @relation(fields: [batch_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
|
||||
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])
|
||||
project projects @relation(fields: [project_id], references: [id])
|
||||
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
|
||||
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? @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])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
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")
|
||||
@@ -936,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")
|
||||
@@ -958,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")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import "dotenv/config";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Use the shared, adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
// dotenv is imported first so DATABASE_URL is set before database.ts reads it.
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
const PERMISSIONS: {
|
||||
name: string;
|
||||
@@ -94,12 +95,6 @@ const PERMISSIONS: {
|
||||
module: "offers",
|
||||
description: "Mazat nabídky",
|
||||
},
|
||||
{
|
||||
name: "offers.export",
|
||||
display_name: "Exportovat nabídku",
|
||||
module: "offers",
|
||||
description: "Exportovat nabídky do PDF",
|
||||
},
|
||||
|
||||
// Objednávky
|
||||
{
|
||||
@@ -126,12 +121,6 @@ const PERMISSIONS: {
|
||||
module: "orders",
|
||||
description: "Mazat objednávky",
|
||||
},
|
||||
{
|
||||
name: "orders.export",
|
||||
display_name: "Exportovat objednávku",
|
||||
module: "orders",
|
||||
description: "Exportovat objednávky do PDF",
|
||||
},
|
||||
|
||||
// Faktury
|
||||
{
|
||||
@@ -158,12 +147,6 @@ const PERMISSIONS: {
|
||||
module: "invoices",
|
||||
description: "Mazat faktury",
|
||||
},
|
||||
{
|
||||
name: "invoices.export",
|
||||
display_name: "Exportovat fakturu",
|
||||
module: "invoices",
|
||||
description: "Exportovat faktury do PDF",
|
||||
},
|
||||
|
||||
// Projekty
|
||||
{
|
||||
@@ -172,6 +155,12 @@ const PERMISSIONS: {
|
||||
module: "projects",
|
||||
description: "Prohlížet seznam projektů",
|
||||
},
|
||||
{
|
||||
name: "projects.create",
|
||||
display_name: "Vytvořit projekt",
|
||||
module: "projects",
|
||||
description: "Ručně vytvářet projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.edit",
|
||||
display_name: "Upravit projekt",
|
||||
@@ -309,6 +298,22 @@ const PERMISSIONS: {
|
||||
];
|
||||
|
||||
async function main() {
|
||||
// HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and
|
||||
// role_permissions (and seeds an admin/admin user) — purely dev-convenience
|
||||
// behavior. Production baseline data must come from tracked Prisma migrations,
|
||||
// never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on
|
||||
// production so the destructive wipe can never hit a live database.
|
||||
if (process.env.APP_ENV === "production") {
|
||||
throw new Error(
|
||||
"Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " +
|
||||
"Seed maže všechna oprávnění a je určen pouze pro vývoj. " +
|
||||
"Produkční data musí být zavedena migrací.\n" +
|
||||
"Refusing to run: prisma/seed.ts must NEVER run on production " +
|
||||
"(APP_ENV=production). It wipes all permissions and is dev-only; " +
|
||||
"seed production baseline data via a migration instead.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Seeding permissions...");
|
||||
|
||||
// 1. Clear existing permissions and re-insert current set
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import "../src/config/env";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import fs from "fs";
|
||||
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
async function main() {
|
||||
if (!nasFinancialsManager.isConfigured()) {
|
||||
@@ -51,6 +51,39 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read-back verification before we null out the ONLY DB copy of the bytes.
|
||||
// The manager uses writeFileSync (synchronous) and returns a relative path;
|
||||
// resolve it back to disk and confirm the file exists and its size matches
|
||||
// the source buffer. A 0-byte/truncated/corrupt write would otherwise lose
|
||||
// the invoice permanently once file_data is set to null. If verification
|
||||
// fails we KEEP file_data and report the row as failed.
|
||||
const expectedSize = rec.file_data.length;
|
||||
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
||||
let writtenSize = -1;
|
||||
if (readBack) {
|
||||
writtenSize = readBack.data.length;
|
||||
} else {
|
||||
// Fall back to a direct stat in case readReceivedInvoice's path guard
|
||||
// rejects an otherwise-valid path; either way we need a confirmed size.
|
||||
try {
|
||||
writtenSize = fs.statSync(
|
||||
(nasFinancialsManager as unknown as { basePath: string }).basePath +
|
||||
"/" +
|
||||
result.filePath,
|
||||
).size;
|
||||
} catch {
|
||||
writtenSize = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (writtenSize !== expectedSize) {
|
||||
console.error(
|
||||
` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`,
|
||||
);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.received_invoices.update({
|
||||
where: { id: rec.id },
|
||||
data: { file_path: result.filePath, file_data: null },
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import crypto from 'crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import "dotenv/config";
|
||||
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
|
||||
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
|
||||
import prisma from "../src/config/database";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function decrypt(ciphertext: string, keyHex: string): string {
|
||||
const key = Buffer.from(keyHex, 'hex');
|
||||
const [ivHex, encHex, tagHex] = ciphertext.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string, keyHex: string): string {
|
||||
const key = Buffer.from(keyHex, 'hex');
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
||||
}
|
||||
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
|
||||
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
|
||||
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
|
||||
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
|
||||
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
|
||||
// legacy user. These key-parameterized helpers accept the old/new keys passed on
|
||||
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
|
||||
const decrypt = (ciphertext: string, keyHex: string): string =>
|
||||
decryptWithKey(ciphertext, keyHex);
|
||||
const encrypt = (plaintext: string, keyHex: string): string =>
|
||||
encryptWithKey(plaintext, keyHex);
|
||||
|
||||
async function main() {
|
||||
const oldKey = process.argv[2];
|
||||
const newKey = process.argv[3];
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
if (!oldKey || !newKey) {
|
||||
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
|
||||
console.error(
|
||||
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -46,12 +40,14 @@ async function main() {
|
||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||
const reEncrypted = encrypt(decrypted, newKey);
|
||||
decrypt(reEncrypted, newKey); // verify round-trip
|
||||
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
|
||||
console.log(
|
||||
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
|
||||
}
|
||||
}
|
||||
console.log('\nDry run complete. No changes made.');
|
||||
console.log("\nDry run complete. No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,13 +55,19 @@ async function main() {
|
||||
for (const user of users) {
|
||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||
const reEncrypted = encrypt(decrypted, newKey);
|
||||
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: { totp_secret: reEncrypted },
|
||||
});
|
||||
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nAll TOTP secrets re-encrypted successfully.');
|
||||
console.log("\nAll TOTP secrets re-encrypted successfully.");
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -2,7 +2,13 @@ import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AdminApp from "./admin/AdminApp";
|
||||
|
||||
// Bootstrap loader: renders before MuiProvider (and its global styles) mount,
|
||||
// so it must be fully self-contained — no theme vars, no global CSS classes.
|
||||
// Background follows the persisted theme attribute set by ThemeContext.
|
||||
function AdminLoader() {
|
||||
const light =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.getAttribute("data-theme") === "light";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -10,10 +16,20 @@ function AdminLoader() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg-primary)",
|
||||
background: light ? "#f4f3f1" : "#0f0f0f",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
<style>{"@keyframes boha-boot-spin{to{transform:rotate(360deg)}}"}</style>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "2px solid #d63031",
|
||||
borderTopColor: "transparent",
|
||||
borderRadius: "50%",
|
||||
animation: "boha-boot-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
468
src/__tests__/ai.test.ts
Normal file
468
src/__tests__/ai.test.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config as appConfig } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import aiRoutes from "../routes/admin/ai";
|
||||
import {
|
||||
computeCostUsd,
|
||||
recordUsage,
|
||||
getMonthSpendUsd,
|
||||
getBudgetUsd,
|
||||
assertBudgetAvailable,
|
||||
isConfigured,
|
||||
listConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
appendConversationMessages,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
} from "../services/ai.service";
|
||||
|
||||
const KIND = "test_ai"; // marker so we only clean our own rows
|
||||
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||
const CONV_UID_B = 999999992;
|
||||
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||
});
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { title: { contains: HIST_MARKER } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai.service cost + budget", () => {
|
||||
it("computes Sonnet 4.6 cost from tokens", () => {
|
||||
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
|
||||
expect(
|
||||
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
|
||||
).toBeCloseTo(18, 6);
|
||||
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
|
||||
0.0195,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("records usage with the computed cost", async () => {
|
||||
await recordUsage({
|
||||
userId: null,
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
inputTokens: 4000,
|
||||
outputTokens: 500,
|
||||
});
|
||||
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
|
||||
expect(rows.length).toBe(1);
|
||||
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
||||
});
|
||||
|
||||
it("sums only the current month's spend (excludes prior months)", async () => {
|
||||
// Measure the baseline so the assertion is robust to any OTHER current-month
|
||||
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
|
||||
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
|
||||
// upper bound.
|
||||
const before = await getMonthSpendUsd();
|
||||
|
||||
await recordUsage({
|
||||
userId: null,
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
}); // +$3 this month
|
||||
// An old row (year 2000) must NOT count toward the current month.
|
||||
await prisma.ai_usage.create({
|
||||
data: {
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cost_usd: 3,
|
||||
created_at: new Date("2000-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const after = await getMonthSpendUsd();
|
||||
// Only the $3 current-month row moved the needle; the year-2000 $3 is
|
||||
// excluded. The delta is exactly $3 (not $6).
|
||||
expect(after - before).toBeCloseTo(3, 6);
|
||||
});
|
||||
|
||||
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
||||
const budget = await getBudgetUsd();
|
||||
// Under budget → null (allowed)
|
||||
expect(await assertBudgetAvailable()).toBeNull();
|
||||
// Push spend over budget for this month, then it must block with 402.
|
||||
// Tag with a unique marker so we can delete EXACTLY this row at the end of
|
||||
// the test — that keeps the inflated spend from leaking into any other
|
||||
// current-month test regardless of execution order (the beforeEach kind=KIND
|
||||
// cleanup would catch it too, but cleaning in-test makes it order-proof).
|
||||
const overBudget = await prisma.ai_usage.create({
|
||||
data: {
|
||||
kind: KIND,
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cost_usd: budget + 1,
|
||||
created_at: new Date(),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const blocked = await assertBudgetAvailable();
|
||||
expect(blocked).not.toBeNull();
|
||||
expect(blocked?.status).toBe(402);
|
||||
} finally {
|
||||
// Remove the over-budget row immediately so it cannot inflate
|
||||
// getMonthSpendUsd for any subsequently-running test.
|
||||
await prisma.ai_usage
|
||||
.delete({ where: { id: overBudget.id } })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("isConfigured reflects the API key presence", () => {
|
||||
expect(typeof isConfigured()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai.service conversations", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.ai_conversations.deleteMany({
|
||||
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||
});
|
||||
});
|
||||
|
||||
// Narrow a service result to its error branch with a clear failure message.
|
||||
const expectError = (
|
||||
r: { error: string; status: number } | { data: unknown },
|
||||
status: number,
|
||||
) => {
|
||||
if (!("error" in r))
|
||||
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
|
||||
expect(r.status).toBe(status);
|
||||
};
|
||||
|
||||
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
expect(conv.title).toBe("Nová konverzace");
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||
{ role: "assistant", content: "Nevím, ale…" },
|
||||
]);
|
||||
const list = await listConversations(CONV_UID_A);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||
if (!("data" in msgs)) throw new Error("expected messages, got an error");
|
||||
expect(msgs.data.map((m) => m.content)).toEqual([
|
||||
"Kolik je hodin v Praze?",
|
||||
"Nevím, ale…",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not overwrite a renamed title on later appends", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||
]);
|
||||
const list = await listConversations(CONV_UID_A);
|
||||
expect(list[0].title).toBe("Moje téma");
|
||||
});
|
||||
|
||||
it("bumps updated_at on append", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
|
||||
// second-precision, so compare against a year rather than ms deltas).
|
||||
await prisma.ai_conversations.update({
|
||||
where: { id: conv.id },
|
||||
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
|
||||
});
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "ping" },
|
||||
]);
|
||||
const after = (await listConversations(CONV_UID_A))[0].updated_at;
|
||||
expect(after.getFullYear()).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it("isolates conversations per user (404 on every op across owners)", async () => {
|
||||
const a = await createConversation(CONV_UID_A);
|
||||
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
|
||||
expectError(
|
||||
await appendConversationMessages(CONV_UID_B, a.id, [
|
||||
{ role: "user", content: "x" },
|
||||
]),
|
||||
404,
|
||||
);
|
||||
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
|
||||
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
|
||||
});
|
||||
|
||||
it("cascade-deletes messages with the conversation", async () => {
|
||||
const conv = await createConversation(CONV_UID_A);
|
||||
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||
{ role: "user", content: "x" },
|
||||
]);
|
||||
await deleteConversation(CONV_UID_A, conv.id);
|
||||
const remaining = await prisma.ai_chat_messages.count({
|
||||
where: { conversation_id: conv.id },
|
||||
});
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP /api/admin/ai", () => {
|
||||
let app: Awaited<ReturnType<typeof buildAiApp>>;
|
||||
let adminToken: string;
|
||||
let noPermToken: string;
|
||||
let noPermRoleId: number;
|
||||
let noPermUserId: number;
|
||||
let savedKey: string;
|
||||
|
||||
async function buildAiApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||
return a;
|
||||
}
|
||||
function token(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
appConfig.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
savedKey = appConfig.anthropic.apiKey;
|
||||
app = await buildAiApp();
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("admin not found");
|
||||
adminToken = token({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
const stamp = Date.now();
|
||||
const role = await prisma.roles.create({
|
||||
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
|
||||
});
|
||||
noPermRoleId = role.id;
|
||||
const u = await prisma.users.create({
|
||||
data: {
|
||||
username: `noperm_ai_${stamp}`,
|
||||
first_name: "No",
|
||||
last_name: "Perm",
|
||||
email: `noperm_ai_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
noPermUserId = u.id;
|
||||
noPermToken = token({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
roleName: role.name,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||
if (noPermUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: noPermUserId } })
|
||||
.catch(() => {});
|
||||
if (noPermRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: noPermRoleId } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
it("GET /usage requires ai.use", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/usage",
|
||||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("GET /usage returns spend + budget for an admin", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/usage",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(typeof body.data.budget_usd).toBe("number");
|
||||
expect(typeof body.data.month_spend_usd).toBe("number");
|
||||
});
|
||||
|
||||
it("POST /chat returns 503 when AI is not configured", async () => {
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = "";
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/chat",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(503);
|
||||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||
});
|
||||
|
||||
it("GET /conversations requires ai.use", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||
const created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: `${HIST_MARKER} t` },
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const id = created.json().data.conversation.id as number;
|
||||
|
||||
const appended = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||
});
|
||||
expect(appended.statusCode).toBe(200);
|
||||
|
||||
const msgs = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(
|
||||
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||
).toContain("ahoj");
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/admin/ai/conversations/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: `${HIST_MARKER} renamed` },
|
||||
});
|
||||
expect(renamed.json().data.conversation.title).toBe(
|
||||
`${HIST_MARKER} renamed`,
|
||||
);
|
||||
|
||||
const del = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/ai/conversations/${id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(del.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("GET messages of a non-existent conversation → 404", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/conversations/999999000/messages",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /conversations requires ai.use", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: {
|
||||
Authorization: `Bearer ${noPermToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: "x" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("GET /conversations lists the user's conversations", async () => {
|
||||
const created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: `${HIST_MARKER} listed` },
|
||||
});
|
||||
const id = created.json().data.conversation.id as number;
|
||||
const list = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/ai/conversations",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(
|
||||
list.json().data.conversations.map((c: { id: number }) => c.id),
|
||||
).toContain(id);
|
||||
});
|
||||
|
||||
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
|
||||
const patch = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/admin/ai/conversations/999999000",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { title: "" },
|
||||
});
|
||||
expect(patch.statusCode).toBe(400);
|
||||
|
||||
const append = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/ai/conversations/999999000/messages",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { messages: [] },
|
||||
});
|
||||
expect(append.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
116
src/__tests__/ares.test.ts
Normal file
116
src/__tests__/ares.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { aresLookupByIco, aresSearchByName } from "../services/ares.service";
|
||||
|
||||
// ARES is a true external — mock global fetch (the only allowed mock class).
|
||||
const SUBJECT = {
|
||||
ico: "22599851",
|
||||
obchodniJmeno: "BOHA Automation s.r.o.",
|
||||
dic: "CZ22599851",
|
||||
sidlo: {
|
||||
nazevStatu: "Česká republika",
|
||||
nazevObce: "Turnov",
|
||||
nazevCastiObce: "Turnov",
|
||||
nazevUlice: "Nádražní",
|
||||
cisloDomovni: 485,
|
||||
psc: 51101,
|
||||
textovaAdresa: "Nádražní 485, 51101 Turnov",
|
||||
},
|
||||
};
|
||||
|
||||
function mockFetchOnce(status: number, body?: unknown) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("aresLookupByIco", () => {
|
||||
it("maps a subject to the normalized prefill shape", async () => {
|
||||
mockFetchOnce(200, SUBJECT);
|
||||
const res = await aresLookupByIco("22599851");
|
||||
expect(res).toEqual({
|
||||
ico: "22599851",
|
||||
dic: "CZ22599851",
|
||||
name: "BOHA Automation s.r.o.",
|
||||
street: "Nádražní 485",
|
||||
city: "Turnov",
|
||||
postal_code: "511 01",
|
||||
country: "Česká republika",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => {
|
||||
mockFetchOnce(200, {
|
||||
...SUBJECT,
|
||||
sidlo: {
|
||||
...SUBJECT.sidlo,
|
||||
cisloOrientacni: 12,
|
||||
cisloOrientacniPismeno: "a",
|
||||
},
|
||||
});
|
||||
const res = await aresLookupByIco("22599851");
|
||||
expect("street" in res && res.street).toBe("Nádražní 485/12a");
|
||||
});
|
||||
|
||||
it("rejects a non-8-digit IČO without calling ARES", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" });
|
||||
expect(await aresLookupByIco("abcdefgh")).toEqual({
|
||||
error: "invalid_ico",
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an IČO with stray whitespace", async () => {
|
||||
const spy = mockFetchOnce(200, SUBJECT);
|
||||
const res = await aresLookupByIco(" 225 99851 ");
|
||||
expect("ico" in res && res.ico).toBe("22599851");
|
||||
expect(String(spy.mock.calls[0][0])).toContain("/22599851");
|
||||
});
|
||||
|
||||
it("maps 404 to not_found and network failure to ares_unavailable", async () => {
|
||||
mockFetchOnce(404);
|
||||
expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" });
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT"));
|
||||
expect(await aresLookupByIco("12345678")).toEqual({
|
||||
error: "ares_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aresSearchByName", () => {
|
||||
it("maps the result list and sends the name in the POST body", async () => {
|
||||
const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] });
|
||||
const res = await aresSearchByName("BOHA Automation");
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
if (Array.isArray(res)) {
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].name).toBe("BOHA Automation s.r.o.");
|
||||
expect(res[0].postal_code).toBe("511 01");
|
||||
}
|
||||
const init = spy.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
obchodniJmeno: "BOHA Automation",
|
||||
pocet: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns [] for a blank query without calling ARES", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
expect(await aresSearchByName(" ")).toEqual([]);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps upstream failure to ares_unavailable", async () => {
|
||||
mockFetchOnce(503);
|
||||
expect(await aresSearchByName("BOHA")).toEqual({
|
||||
error: "ares_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
445
src/__tests__/attendance.test.ts
Normal file
445
src/__tests__/attendance.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import attendanceRoutes from "../routes/admin/attendance";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression tests for the attendance create/edit wire format.
|
||||
//
|
||||
// The admin create/edit forms send COMBINED local datetimes
|
||||
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
|
||||
// for arrival_time / departure_time / break_start / break_end, and the
|
||||
// service parses them with `new Date(...)`. Commit 519edce validated these
|
||||
// fields with `timeString` (bare HH:MM), which rejected every create/edit
|
||||
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
|
||||
// entirely, so breaks were silently stripped on create. These tests pin the
|
||||
// combined-datetime contract at the HTTP route level.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// All fixtures live on far-future dates so they can never collide with real
|
||||
// rows in the throwaway test DB; cleanup deletes exactly this range.
|
||||
const RANGE_START = new Date("2098-01-01T00:00:00");
|
||||
const RANGE_END = new Date("2099-01-01T00:00:00");
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
|
||||
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authPost(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authPut(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupFixtureRows() {
|
||||
await prisma.attendance.deleteMany({
|
||||
where: {
|
||||
user_id: adminUserId,
|
||||
shift_date: { gte: RANGE_START, lt: RANGE_END },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtureRows();
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/attendance (combined datetimes)", () => {
|
||||
it("creates a work record with arrival+departure datetimes and persists them", async () => {
|
||||
const arrival = "2098-03-10T08:00:00";
|
||||
const departure = "2098-03-10T16:30:00";
|
||||
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-10",
|
||||
leave_type: "work",
|
||||
arrival_time: arrival,
|
||||
departure_time: departure,
|
||||
notes: "attendance_wire_test work",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec).toBeTruthy();
|
||||
expect(rec!.leave_type).toBe("work");
|
||||
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
|
||||
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
|
||||
});
|
||||
|
||||
it("persists break_start/break_end on create (previously silently stripped)", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-11",
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-11T08:00:00",
|
||||
departure_time: "2098-03-11T16:30:00",
|
||||
break_start: "2098-03-11T12:00:00",
|
||||
break_end: "2098-03-11T12:30:00",
|
||||
notes: "attendance_wire_test break",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:30:00").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a leave record with NO time fields", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-12",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test leave",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(Number(rec!.leave_hours)).toBe(8);
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
|
||||
// The AttendanceCreate page historically always sent empty strings for
|
||||
// the unfilled time fields — even for leave records — which 400'd EVERY
|
||||
// submit after 519edce. The schema must treat "" as "not set".
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-13",
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
arrival_time: "",
|
||||
departure_time: "",
|
||||
break_start: "",
|
||||
break_end: "",
|
||||
notes: "attendance_wire_test empty strings",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("sick");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
expect(rec!.break_start).toBeNull();
|
||||
expect(rec!.break_end).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/attendance (complete month, no truncation)", () => {
|
||||
it("returns every record of a month with more than 100 rows", async () => {
|
||||
// The admin month view computes the KPI cards and summary totals from
|
||||
// this single fetch, so it must cover the COMPLETE month. With the old
|
||||
// 100-row pagination cap, months with >100 records were silently
|
||||
// truncated (newest-first) and the screen totals dropped the earliest
|
||||
// days while the print stayed complete.
|
||||
const rows = [];
|
||||
for (let day = 1; day <= 30; day++) {
|
||||
for (let s = 0; s < 4; s++) {
|
||||
rows.push({
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 4, day, 12, 0, 0),
|
||||
leave_type: "work" as const,
|
||||
arrival_time: new Date(2098, 4, day, 6 + s, 0, 0),
|
||||
departure_time: new Date(2098, 4, day, 10 + s, 0, 0),
|
||||
notes: "attendance_wire_test bulk month",
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.attendance.createMany({ data: rows });
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=5&limit=2000",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(120);
|
||||
expect(body.pagination.total).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
|
||||
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
|
||||
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
|
||||
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
|
||||
// previous day) shifted the whole window a day back: the June list
|
||||
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
|
||||
// 2098-06 / 2098-07 boundary.
|
||||
const juneLast = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
|
||||
leave_type: "work",
|
||||
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
|
||||
departure_time: new Date(2098, 5, 30, 16, 0, 0),
|
||||
notes: "attendance_wire_test june-last-day",
|
||||
},
|
||||
});
|
||||
const julyFirst = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
|
||||
leave_type: "work",
|
||||
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
|
||||
departure_time: new Date(2098, 6, 1, 16, 0, 0),
|
||||
notes: "attendance_wire_test july-first-day",
|
||||
},
|
||||
});
|
||||
|
||||
const june = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=6&limit=100",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(june.statusCode).toBe(200);
|
||||
const juneIds = june.json().data.map((r: { id: number }) => r.id);
|
||||
expect(juneIds).toContain(juneLast.id);
|
||||
expect(juneIds).not.toContain(julyFirst.id);
|
||||
|
||||
const july = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/attendance?year=2098&month=7&limit=100",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(july.statusCode).toBe(200);
|
||||
const julyIds = july.json().data.map((r: { id: number }) => r.id);
|
||||
expect(julyIds).toContain(julyFirst.id);
|
||||
expect(julyIds).not.toContain(juneLast.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
|
||||
it("rejects a second leave record on the SAME day", async () => {
|
||||
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test dup-leave-first",
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
|
||||
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
|
||||
// day — a same-day duplicate leave sailed through undetected.
|
||||
const second = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test dup-leave-second",
|
||||
});
|
||||
expect(second.statusCode).toBe(400);
|
||||
expect(second.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
|
||||
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-10",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test neighbor-day-first",
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
// Old bug (the other half): creating a record for the NEXT day queried
|
||||
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
|
||||
// with a false "záznam o nepřítomnosti již existuje".
|
||||
const nextDay = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-08-11",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test neighbor-day-next",
|
||||
});
|
||||
expect(nextDay.statusCode).toBe(201);
|
||||
expect(nextDay.json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
|
||||
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 14, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-14T08:00:00"),
|
||||
departure_time: new Date("2098-03-14T16:30:00"),
|
||||
notes: "attendance_wire_test update",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-14T22:00:00",
|
||||
departure_time: "2098-03-15T06:00:00",
|
||||
break_start: "2098-03-15T02:00:00",
|
||||
break_end: "2098-03-15T02:30:00",
|
||||
notes: "attendance_wire_test updated",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.arrival_time?.getTime()).toBe(
|
||||
new Date("2098-03-14T22:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.departure_time?.getTime()).toBe(
|
||||
new Date("2098-03-15T06:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:30:00").getTime(),
|
||||
);
|
||||
expect(rec!.notes).toBe("attendance_wire_test updated");
|
||||
});
|
||||
|
||||
it("clears times when null is sent (leave-type edit)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 16, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-16T08:00:00"),
|
||||
departure_time: new Date("2098-03-16T16:30:00"),
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
arrival_time: null,
|
||||
departure_time: null,
|
||||
break_start: null,
|
||||
break_end: null,
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,72 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config/env";
|
||||
import prisma from "../config/database";
|
||||
|
||||
// Prefix for fixtures so cleanup never touches real data.
|
||||
const N = "auth_svc_test_";
|
||||
|
||||
let testUserId: number;
|
||||
let testUsername: string;
|
||||
let testRoleName: string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up any leftover fixtures from a previous failed run.
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
|
||||
// returns a full AuthData. We don't need a specific role — any active user
|
||||
// works — but we record its role so we can assert roleName precisely.
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
// DUMMY_HASH used elsewhere in the suite; we never verify a password
|
||||
// here, only that the token's `sub` resolves to this user.
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Auth",
|
||||
last_name: "Service",
|
||||
is_active: true,
|
||||
role_id: role.id,
|
||||
},
|
||||
include: { roles: true },
|
||||
});
|
||||
testUserId = user.id;
|
||||
testUsername = user.username;
|
||||
testRoleName = user.roles?.name ?? null;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
describe("auth service", () => {
|
||||
describe("verifyAccessToken", () => {
|
||||
it("returns AuthData for a valid token signed for a real user", async () => {
|
||||
const token = jwt.sign(
|
||||
{ sub: testUserId, username: testUsername, role: testRoleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
|
||||
);
|
||||
|
||||
const result = await verifyAccessToken(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe(testUserId);
|
||||
expect(result!.username).toBe(testUsername);
|
||||
expect(result!.roleName).toBe(testRoleName);
|
||||
// Permissions resolve to an array (admins get all, others get role perms).
|
||||
expect(Array.isArray(result!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
@@ -35,6 +97,14 @@ describe("auth service", () => {
|
||||
});
|
||||
|
||||
describe("hashToken", () => {
|
||||
it("matches the known SHA-256 vector for a fixed input", () => {
|
||||
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
|
||||
// different but still 64-hex-deterministic hash) is caught.
|
||||
expect(hashToken("hello")).toBe(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
);
|
||||
});
|
||||
|
||||
it("produces deterministic SHA-256 hex output", () => {
|
||||
const t1 = hashToken("hello");
|
||||
const t2 = hashToken("hello");
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "auth_test_";
|
||||
const TEST_USERNAME = `${N}user`;
|
||||
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
|
||||
|
||||
let testUserId: number;
|
||||
|
||||
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
|
||||
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
|
||||
const cookies = res.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
return arr.find((c: string) => c.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run, plus its tokens.
|
||||
const leftovers = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (leftovers.length) {
|
||||
await prisma.refresh_tokens.deleteMany({
|
||||
where: { user_id: { in: leftovers.map((u) => u.id) } },
|
||||
});
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
}
|
||||
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
// Hash the known password with the SAME bcrypt cost the app uses so login's
|
||||
// bcrypt.compare succeeds.
|
||||
const passwordHash = await bcrypt.hash(
|
||||
TEST_PASSWORD,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_USERNAME,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: passwordHash,
|
||||
first_name: "Auth",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refresh_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 for a valid user with the wrong password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.access_token.length).toBeGreaterThan(0);
|
||||
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
expect(body.data.user.username).toBe(TEST_USERNAME);
|
||||
|
||||
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
|
||||
const cookie = extractCookie(res, "refresh_token");
|
||||
expect(cookie).toBeTruthy();
|
||||
const rawCookie = rawSetCookie(res, "refresh_token")!;
|
||||
expect(rawCookie).toMatch(/HttpOnly/i);
|
||||
expect(rawCookie).toMatch(/SameSite=Strict/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/refresh", () => {
|
||||
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid/unknown refresh token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: "not-a-real-token" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
|
||||
// First log in to obtain a valid refresh cookie.
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
expect(loginRes.statusCode).toBe(200);
|
||||
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
|
||||
expect(originalRefresh).toBeTruthy();
|
||||
|
||||
const refreshRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: originalRefresh },
|
||||
});
|
||||
expect(refreshRes.statusCode).toBe(200);
|
||||
const body = refreshRes.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
|
||||
// The refresh token must be ROTATED: a new cookie value, different from
|
||||
// the one we presented.
|
||||
const rotated = extractCookie(refreshRes, "refresh_token");
|
||||
expect(rotated).toBeTruthy();
|
||||
expect(rotated).not.toBe(originalRefresh);
|
||||
|
||||
// Reuse-detection: presenting the now-consumed original token must fail.
|
||||
const reuseRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: originalRefresh },
|
||||
});
|
||||
expect(reuseRes.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/logout", () => {
|
||||
it("clears refresh token cookie", async () => {
|
||||
it("clears the refresh token cookie", async () => {
|
||||
// Log in so logout has a real token to delete.
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||
});
|
||||
const refresh = extractCookie(loginRes, "refresh_token")!;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/logout",
|
||||
cookies: { refresh_token: refresh },
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// The response must actively clear the cookie — an expiry in the past
|
||||
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
|
||||
const raw = rawSetCookie(res, "refresh_token");
|
||||
expect(raw).toBeTruthy();
|
||||
const cleared =
|
||||
/Max-Age=0/i.test(raw!) ||
|
||||
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
|
||||
/^refresh_token=;/.test(raw!);
|
||||
expect(cleared).toBe(true);
|
||||
|
||||
// The deleted token must no longer be usable for refresh.
|
||||
const reuseRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
cookies: { refresh_token: refresh },
|
||||
});
|
||||
expect(reuseRes.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
152
src/__tests__/bank-accounts.test.ts
Normal file
152
src/__tests__/bank-accounts.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import bankAccountsRoutes from "../routes/admin/bank-accounts";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
// Fixture marker so cleanup never touches real data.
|
||||
const N = "bank_accounts_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let token: string;
|
||||
const createdIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(bankAccountsRoutes, {
|
||||
prefix: "/api/admin/bank-accounts",
|
||||
});
|
||||
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Admin role bypasses the settings.banking permission guard.
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Test setup: no admin role found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Bank",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
token = jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.bank_accounts
|
||||
.deleteMany({ where: { id: { in: createdIds } } })
|
||||
.catch(() => {});
|
||||
}
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const auth = { authorization: () => `Bearer ${token}` };
|
||||
|
||||
describe("bank accounts — flat-body create/update persists all fields", () => {
|
||||
it("creates an account from a flat top-level body and persists every field", async () => {
|
||||
const payload = {
|
||||
account_name: `${N}Hlavní účet`,
|
||||
bank_name: "Komerční banka",
|
||||
account_number: "123456789/0100",
|
||||
iban: "CZ6508000000192000145399",
|
||||
bic: "KOMBCZPP",
|
||||
currency: "CZK",
|
||||
is_default: true,
|
||||
};
|
||||
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload,
|
||||
});
|
||||
expect(post.statusCode).toBe(201);
|
||||
const id = post.json().data.id as number;
|
||||
expect(typeof id).toBe("number");
|
||||
createdIds.push(id);
|
||||
|
||||
// Read back via the list endpoint and confirm NONE of the fields are blank.
|
||||
const row = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
expect(row?.account_name).toBe(payload.account_name);
|
||||
expect(row?.bank_name).toBe(payload.bank_name);
|
||||
expect(row?.account_number).toBe(payload.account_number);
|
||||
expect(row?.iban).toBe(payload.iban);
|
||||
expect(row?.bic).toBe(payload.bic);
|
||||
expect(row?.currency).toBe("CZK");
|
||||
expect(row?.is_default).toBe(true);
|
||||
});
|
||||
|
||||
it("updates an account from a flat body that also carries the id", async () => {
|
||||
const created = await prisma.bank_accounts.create({
|
||||
data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" },
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
// Mirrors the client payload: spread fields + id (id routes the URL/method,
|
||||
// is declared in the schema, and is ignored by the route which uses :id).
|
||||
const put = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/bank-accounts/${created.id}`,
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload: {
|
||||
id: created.id,
|
||||
account_name: `${N}upraveno`,
|
||||
bank_name: "Nová banka",
|
||||
iban: "CZ9999",
|
||||
currency: "EUR",
|
||||
is_default: false,
|
||||
},
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
|
||||
const row = await prisma.bank_accounts.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(row?.account_name).toBe(`${N}upraveno`);
|
||||
expect(row?.bank_name).toBe("Nová banka");
|
||||
expect(row?.iban).toBe("CZ9999");
|
||||
expect(row?.currency).toBe("EUR");
|
||||
});
|
||||
|
||||
it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => {
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
headers: { authorization: auth.authorization() },
|
||||
payload: { bank: { account_name: `${N}nested`, bank_name: "X" } },
|
||||
});
|
||||
expect(post.statusCode).toBe(400);
|
||||
|
||||
// And nothing was persisted under that name.
|
||||
const leaked = await prisma.bank_accounts.findFirst({
|
||||
where: { account_name: `${N}nested` },
|
||||
});
|
||||
expect(leaked).toBeNull();
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/bank-accounts/",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
132
src/__tests__/company-settings-numbering.test.ts
Normal file
132
src/__tests__/company-settings-numbering.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import companySettingsRoutes from "../routes/admin/company-settings";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "settings_numbering_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let token: string;
|
||||
|
||||
// Snapshot the prior values of the four numbering fields we mutate so we can
|
||||
// restore them in afterAll — other tests read company_settings and must not be
|
||||
// poisoned by our writes.
|
||||
let priorValues: {
|
||||
issued_order_number_pattern: string | null;
|
||||
issued_order_type_code: string | null;
|
||||
project_number_pattern: string | null;
|
||||
project_type_code: string | null;
|
||||
} | null = null;
|
||||
let settingsId: number | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(companySettingsRoutes, {
|
||||
prefix: "/api/admin/company-settings",
|
||||
});
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run.
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
|
||||
// Attach the fixture user to the admin role so the PUT/GET permission guards
|
||||
// (settings.company / settings.system) pass.
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Test setup: no admin role found — seed the database");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Settings",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: false,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
token = jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
|
||||
// Snapshot existing settings so we can restore after the suite.
|
||||
const existing = await prisma.company_settings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
issued_order_number_pattern: true,
|
||||
issued_order_type_code: true,
|
||||
project_number_pattern: true,
|
||||
project_type_code: true,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
settingsId = existing.id;
|
||||
priorValues = {
|
||||
issued_order_number_pattern: existing.issued_order_number_pattern,
|
||||
issued_order_type_code: existing.issued_order_type_code,
|
||||
project_number_pattern: existing.project_number_pattern,
|
||||
project_type_code: existing.project_type_code,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Restore the four numbering fields to their pre-test values.
|
||||
if (settingsId !== null && priorValues !== null) {
|
||||
await prisma.company_settings
|
||||
.update({ where: { id: settingsId }, data: priorValues })
|
||||
.catch(() => {});
|
||||
}
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("company-settings numbering — issued orders + projects round-trip", () => {
|
||||
it("persists and returns issued_order_* and project_* numbering fields", async () => {
|
||||
const payload = {
|
||||
issued_order_number_pattern: "{YY}V{NNNN}",
|
||||
issued_order_type_code: "72",
|
||||
project_number_pattern: "{YY}P{NNNN}",
|
||||
project_type_code: "73",
|
||||
};
|
||||
|
||||
const put = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/admin/company-settings/",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().success).toBe(true);
|
||||
|
||||
const get = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/company-settings/",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(get.statusCode).toBe(200);
|
||||
const data = get.json().data;
|
||||
expect(data.issued_order_number_pattern).toBe("{YY}V{NNNN}");
|
||||
expect(data.issued_order_type_code).toBe("72");
|
||||
expect(data.project_number_pattern).toBe("{YY}P{NNNN}");
|
||||
expect(data.project_type_code).toBe("73");
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/company-settings/",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
||||
import {
|
||||
CreateCustomerSchema,
|
||||
UpdateCustomerSchema,
|
||||
} from "../schemas/customers.schema";
|
||||
|
||||
describe("UpdateCustomerSchema", () => {
|
||||
it("rejects empty name", () => {
|
||||
@@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a name longer than 255 chars", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts nullable address/identity fields set to null", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({
|
||||
street: null,
|
||||
city: null,
|
||||
postal_code: null,
|
||||
country: null,
|
||||
company_id: null,
|
||||
vat_id: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateCustomerSchema", () => {
|
||||
it("requires a name", () => {
|
||||
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a full valid payload with all optional fields", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
street: "Main St 1",
|
||||
city: "Praha",
|
||||
postal_code: "11000",
|
||||
country: "CZ",
|
||||
company_id: "12345678",
|
||||
vat_id: "CZ12345678",
|
||||
custom_fields: [{ label: "Note", value: "VIP" }],
|
||||
customer_field_order: ["name", "street"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom_fields as an array of arbitrary objects", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: [
|
||||
{ label: "a", value: 1 },
|
||||
{ nested: { deep: true } },
|
||||
"raw string",
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects custom_fields that is not an array", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: { not: "an array" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects custom_fields longer than the 100-item cap", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects customer_field_order entries that are not strings", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
customer_field_order: ["name", 5],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an over-length nullable field (vat_id > 255)", () => {
|
||||
const result = CreateCustomerSchema.safeParse({
|
||||
name: "Acme Corp",
|
||||
vat_id: "x".repeat(256),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
100
src/__tests__/dashboard.test.ts
Normal file
100
src/__tests__/dashboard.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import dashboardRoutes from "../routes/admin/dashboard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
|
||||
//
|
||||
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
|
||||
// UTC date part. The dashboard used local-midnight boundaries
|
||||
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
|
||||
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
|
||||
// matched zero rows and everyone showed as absent. The boundaries must be
|
||||
// UTC-midnight instants of the LOCAL calendar day.
|
||||
//
|
||||
// Unlike the other suites this one has to use the REAL current date (the
|
||||
// route computes "today" internally), so fixtures are tracked by id and
|
||||
// deleted in afterAll.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
const createdIds: number[] = [];
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
|
||||
return a;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/dashboard — today's attendance window", () => {
|
||||
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
|
||||
const now = new Date();
|
||||
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
|
||||
// part equals the Prague calendar date) — same as punchAction does.
|
||||
const row = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
leave_type: "work",
|
||||
arrival_time: now,
|
||||
departure_time: null,
|
||||
notes: "dashboard_window_test",
|
||||
},
|
||||
});
|
||||
createdIds.push(row.id);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/dashboard",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const attendance = body.data.attendance;
|
||||
expect(attendance).toBeTruthy();
|
||||
const me = attendance.users.find(
|
||||
(u: { user_id: number }) => u.user_id === adminUserId,
|
||||
);
|
||||
expect(me).toBeTruthy();
|
||||
expect(me.status).toBe("in");
|
||||
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
116
src/__tests__/drafts-aggregation.test.ts
Normal file
116
src/__tests__/drafts-aggregation.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createInvoice, getInvoiceStats } from "../services/invoices.service";
|
||||
import { createOffer } from "../services/offers.service";
|
||||
|
||||
// These tests prove that DRAFT documents (a status introduced by the DB-drafts
|
||||
// feature) are NOT counted in period reporting aggregations. A draft is not a
|
||||
// real financial document, so its VAT/amount/created-count must be excluded.
|
||||
//
|
||||
// They hit the real `app_test` DB (per the suite convention) via the service
|
||||
// layer directly. Every row we create is tracked and removed in afterEach so we
|
||||
// leave the DB clean, and we reset the touched number sequences so a finalized
|
||||
// (issued/active) doc created here doesn't leak a consumed number into other
|
||||
// files.
|
||||
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["invoice", "offer"] } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("drafts excluded from invoice monthly stats (VAT)", () => {
|
||||
it("a draft invoice's VAT is NOT in vat_month while an issued one IS", async () => {
|
||||
// Pick a far-future month so no seeded/other-test invoices fall in it and
|
||||
// the totals are deterministic. issue_date drives the stats window.
|
||||
const year = 2099;
|
||||
const month = 7;
|
||||
const issueDate = `${year}-0${month}-15`;
|
||||
|
||||
// Single CZK line, 21% VAT: base 1000, VAT 210. Identical on both docs so
|
||||
// the only difference between "draft excluded" and "issued counted" is the
|
||||
// status filter under test.
|
||||
const item = { quantity: 1, unit_price: 1000, vat_rate: 21 };
|
||||
|
||||
const draft = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
issue_date: issueDate,
|
||||
items: [item],
|
||||
});
|
||||
invoiceIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.invoice_number).toBeNull();
|
||||
|
||||
// Sanity: with ONLY the draft present, vat_month must be empty (draft excluded).
|
||||
const draftOnly = await getInvoiceStats(month, year);
|
||||
expect(draftOnly.vat_month).toEqual([]);
|
||||
expect(draftOnly.vat_month_czk).toBe(0);
|
||||
|
||||
const issued = await createInvoice({
|
||||
status: "issued",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
issue_date: issueDate,
|
||||
items: [item],
|
||||
});
|
||||
invoiceIds.push(issued.id);
|
||||
expect(issued.status).toBe("issued");
|
||||
|
||||
// Now the issued invoice's VAT (210 CZK) must be counted — and ONLY that
|
||||
// one, not the draft's (which would double it to 420 if drafts leaked in).
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
const czk = stats.vat_month.find((v) => v.currency === "CZK");
|
||||
expect(czk?.amount).toBe(210);
|
||||
expect(stats.vat_month_czk).toBe(210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drafts excluded from quotations created-this-month count", () => {
|
||||
it("a draft offer is NOT counted; an active one IS", async () => {
|
||||
// Mirror the dashboard route's exact aggregation: count quotations created
|
||||
// in the window, excluding drafts. Use a tight window around `now` (the
|
||||
// route uses the current month) and assert the delta this test introduces.
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
const countNonDraft = () =>
|
||||
prisma.quotations.count({
|
||||
where: {
|
||||
status: { not: "draft" },
|
||||
created_at: { gte: monthStart, lt: monthEnd },
|
||||
},
|
||||
});
|
||||
|
||||
const before = await countNonDraft();
|
||||
|
||||
const draft = await createOffer({ status: "draft" });
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
|
||||
// The draft must not move the non-draft count.
|
||||
expect(await countNonDraft()).toBe(before);
|
||||
|
||||
const active = await createOffer({ status: "active" });
|
||||
if ("error" in active)
|
||||
throw new Error(`createOffer failed: ${active.error}`);
|
||||
offerIds.push(active.id);
|
||||
|
||||
// The active offer is a real document → the count goes up by exactly 1.
|
||||
expect(await countNonDraft()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
588
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
588
src/__tests__/drafts-deferred-numbering.test.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import {
|
||||
previewIssuedOrderNumber,
|
||||
previewInvoiceNumber,
|
||||
previewOfferNumber,
|
||||
} from "../services/numbering.service";
|
||||
import {
|
||||
createIssuedOrder,
|
||||
updateIssuedOrder,
|
||||
deleteIssuedOrder,
|
||||
} from "../services/issued-orders.service";
|
||||
import {
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
} from "../services/invoices.service";
|
||||
import {
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
invalidateOffer,
|
||||
getOffer,
|
||||
listOffers,
|
||||
} from "../services/offers.service";
|
||||
import quotationsRoutes from "../routes/admin/quotations";
|
||||
|
||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||
const issuedOrderIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
const customerIds: number[] = [];
|
||||
const linkedOrderIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of issuedOrderIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
|
||||
for (const id of linkedOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of customerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
issuedOrderIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
linkedOrderIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
customerIds.length = 0;
|
||||
// Reset every sequence this suite may have touched so preview values are
|
||||
// stable across tests and we don't leak consumed numbers into other files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["issued_order", "invoice", "offer"] } },
|
||||
});
|
||||
});
|
||||
|
||||
/** createOffer + narrow the token union + track cleanup. */
|
||||
async function mkOffer(body: Record<string, unknown> = {}) {
|
||||
const res = await createOffer(body);
|
||||
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
|
||||
offerIds.push(res.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issued orders */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("issued-order drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({}); // defaults to draft
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.po_number).toBeNull();
|
||||
// Preview is unchanged → the sequence was not consumed.
|
||||
const after = (await previewIssuedOrderNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||
const expected = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: draft.id },
|
||||
});
|
||||
expect(row?.status).toBe("sent");
|
||||
expect(row?.po_number).toBe(expected);
|
||||
expect("po_number" in res && res.po_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
issuedOrderIds.push(draft.id);
|
||||
|
||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
const first = (
|
||||
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
||||
)?.po_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "issued_order" },
|
||||
});
|
||||
|
||||
// draft -> sent is no longer valid (already sent); the number must not change.
|
||||
// Drive a second finalize-style update that keeps status sent.
|
||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||
const second = (
|
||||
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
||||
)?.po_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "issued_order" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
||||
const a = await createIssuedOrder({});
|
||||
const b = await createIssuedOrder({});
|
||||
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
|
||||
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
|
||||
issuedOrderIds.push(a.id, b.id);
|
||||
expect(a.po_number).toBeNull();
|
||||
expect(b.po_number).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
||||
const before = (await previewIssuedOrderNumber()).number;
|
||||
const draft = await createIssuedOrder({});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
await deleteIssuedOrder(draft.id);
|
||||
const after = (await previewIssuedOrderNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoices */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("invoice drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.invoice_number).toBeNull();
|
||||
const after = (await previewInvoiceNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
|
||||
const expected = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
const res = await updateInvoice(draft.id, { status: "issued" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.status).toBe("issued");
|
||||
expect(row?.invoice_number).toBe(expected);
|
||||
expect("invoice_number" in res && res.invoice_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing twice does not re-number / double-consume", async () => {
|
||||
const draft = await createInvoice({});
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
await updateInvoice(draft.id, { status: "issued" });
|
||||
const first = (
|
||||
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
||||
)?.invoice_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "invoice" },
|
||||
});
|
||||
|
||||
// issued -> issued is a no-op status-wise; the number must stay put.
|
||||
await updateInvoice(draft.id, { status: "issued" });
|
||||
const second = (
|
||||
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
||||
)?.invoice_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "invoice" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two invoice drafts coexist with null numbers", async () => {
|
||||
const a = await createInvoice({});
|
||||
const b = await createInvoice({});
|
||||
invoiceIds.push(a.id, b.id);
|
||||
expect(a.invoice_number).toBeNull();
|
||||
expect(b.invoice_number).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing", async () => {
|
||||
const before = (await previewInvoiceNumber()).number;
|
||||
const draft = await createInvoice({});
|
||||
await deleteInvoice(draft.id);
|
||||
const after = (await previewInvoiceNumber()).number;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer drafts — deferred numbering", () => {
|
||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||
const before = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.quotation_number).toBeNull();
|
||||
const after = await previewOfferNumber();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
|
||||
const expected = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
|
||||
const res = await updateOffer(draft.id, { status: "active" });
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.status).toBe("active");
|
||||
expect(row?.quotation_number).toBe(expected);
|
||||
expect("quotation_number" in res && res.quotation_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("finalizing twice does not re-number / double-consume", async () => {
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
offerIds.push(draft.id);
|
||||
|
||||
await updateOffer(draft.id, { status: "active" });
|
||||
const first = (
|
||||
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
||||
)?.quotation_number;
|
||||
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
||||
where: { type: "offer" },
|
||||
});
|
||||
|
||||
// active -> active is a no-op; the number must stay put.
|
||||
await updateOffer(draft.id, { status: "active" });
|
||||
const second = (
|
||||
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
||||
)?.quotation_number;
|
||||
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
||||
where: { type: "offer" },
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
||||
});
|
||||
|
||||
it("two offer drafts coexist with null numbers", async () => {
|
||||
const a = await createOffer({});
|
||||
const b = await createOffer({});
|
||||
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
|
||||
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
|
||||
offerIds.push(a.id, b.id);
|
||||
expect(a.quotation_number).toBeNull();
|
||||
expect(b.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("an offer created already-finalized (status active) numbers immediately", async () => {
|
||||
const expected = await previewOfferNumber();
|
||||
const offer = await createOffer({ status: "active" });
|
||||
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
|
||||
offerIds.push(offer.id);
|
||||
expect(offer.quotation_number).toBe(expected);
|
||||
});
|
||||
|
||||
it("deleting a draft releases nothing", async () => {
|
||||
const before = await previewOfferNumber();
|
||||
const draft = await createOffer({});
|
||||
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
||||
await deleteOffer(draft.id);
|
||||
const after = await previewOfferNumber();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — status transition table (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer status transitions (service)", () => {
|
||||
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await updateOffer(draft.id, { status: "ordered" });
|
||||
expect("error" in res && res.error).toBe("invalid_transition");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("allows active -> invalidated via update", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
|
||||
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const rejected = await invalidateOffer(draft.id);
|
||||
expect("error" in rejected && rejected.error).toBe("invalid_transition");
|
||||
|
||||
const ordered = await mkOffer({ status: "ordered" });
|
||||
const ok = await invalidateOffer(ordered.id);
|
||||
expect("error" in ok).toBe(false);
|
||||
|
||||
// invalidated is terminal — a second invalidate is rejected too.
|
||||
const again = await invalidateOffer(ordered.id);
|
||||
expect("error" in again && again.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("getOffer exposes valid_transitions computed from the current status", async () => {
|
||||
const draft = await mkOffer({});
|
||||
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
|
||||
|
||||
const active = await mkOffer({ status: "active" });
|
||||
expect((await getOffer(active.id))?.valid_transitions).toEqual([
|
||||
"ordered",
|
||||
"invalidated",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — editable-state guard (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer editable-state guard (service)", () => {
|
||||
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
|
||||
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
|
||||
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.project_code).toBe("PŮVODNÍ");
|
||||
});
|
||||
|
||||
it("rejects a field edit on an invalidated offer", async () => {
|
||||
const offer = await mkOffer({ status: "invalidated" });
|
||||
const res = await updateOffer(offer.id, { scope_title: "X" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
});
|
||||
|
||||
it("still allows the status-only ordered -> invalidated payload", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — customer FK handling (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer customer FK handling (service)", () => {
|
||||
async function mkCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "drafts_test_zákazník" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
|
||||
const res = await createOffer({ customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("update rejects a nonexistent customer with a clean token", async () => {
|
||||
const offer = await mkOffer({});
|
||||
const res = await updateOffer(offer.id, { customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
|
||||
const customer = await mkCustomer();
|
||||
const offer = await mkOffer({ customer_id: customer.id });
|
||||
expect(offer.customer_id).toBe(customer.id);
|
||||
|
||||
const res = await updateOffer(offer.id, { customer_id: null });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.customer_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — deterministic list ordering */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("listOffers deterministic ordering", () => {
|
||||
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
|
||||
const MARK = `TIEBREAK-${Date.now()}`;
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const o = await mkOffer({ project_code: MARK });
|
||||
ids.push(o.id);
|
||||
}
|
||||
// created_at is second-precision; pin all three to the SAME timestamp so
|
||||
// only the id tiebreak can order them.
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { created_at: new Date("2026-01-15T10:00:00") },
|
||||
});
|
||||
|
||||
const base = { page: 1, limit: 10, skip: 0, search: MARK };
|
||||
const desc = await listOffers({
|
||||
...base,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
});
|
||||
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => b - a),
|
||||
);
|
||||
|
||||
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
|
||||
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — route-level hardening (Czech messages + status codes) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("offers routes — hardening (HTTP)", () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||
|
||||
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "ZMĚNA" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("PUT rejects draft -> ordered with the transition message", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { status: "ordered" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe(
|
||||
'Neplatný přechod stavu z "draft" na "ordered"',
|
||||
);
|
||||
});
|
||||
|
||||
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { items: [{ description: "x".repeat(501) }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { customer_id: 99999999 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Zákazník nenalezen");
|
||||
});
|
||||
|
||||
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: `DRAFTSLINK-${Date.now()}`,
|
||||
quotation_id: offer.id,
|
||||
},
|
||||
});
|
||||
linkedOrderIds.push(order.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().error).toBe(
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
);
|
||||
// The offer survived the rejected delete.
|
||||
expect(
|
||||
await prisma.quotations.findUnique({ where: { id: offer.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
|
||||
const draft = await mkOffer({ project_code: "PŘED" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "PO" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: {
|
||||
entity_type: "quotation",
|
||||
entity_id: draft.id,
|
||||
action: "update",
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit!.old_values).toBeTruthy();
|
||||
expect(audit!.new_values).toBeTruthy();
|
||||
expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
|
||||
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
|
||||
// Unnumbered drafts read "koncept", never "null".
|
||||
expect(audit!.description).toContain("koncept");
|
||||
expect(audit!.description).not.toContain("null");
|
||||
});
|
||||
});
|
||||
@@ -32,3 +32,37 @@ describe("env validation", () => {
|
||||
expect(config.db.url).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("env validation — failure path", () => {
|
||||
// The config singleton runs its validation at module-load time and is then
|
||||
// cached by the module system, so we cannot re-import it with bad env to
|
||||
// observe a throw without complex module-registry resetting. Instead we test
|
||||
// the exact predicates config/env.ts uses to reject bad input — this is the
|
||||
// logic that protects the boot, and the loaded config above proves the
|
||||
// happy path. (Predicates kept in sync with src/config/env.ts.)
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
it("rejects a too-short / non-hex JWT_SECRET", () => {
|
||||
expect(HEX64_RE.test("short")).toBe(false);
|
||||
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
|
||||
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
|
||||
// The real config's secret must pass the very same check.
|
||||
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range or non-numeric PORT", () => {
|
||||
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
|
||||
expect(portValid(parseInt("0", 10))).toBe(false);
|
||||
expect(portValid(parseInt("70000", 10))).toBe(false);
|
||||
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
|
||||
expect(portValid(config.port)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
|
||||
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
|
||||
expect(expiryValid(0)).toBe(false);
|
||||
expect(expiryValid(-1)).toBe(false);
|
||||
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
|
||||
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { toCzk, getRate } from "../services/exchange-rates";
|
||||
import {
|
||||
toCzk,
|
||||
getRate,
|
||||
__resetRateCacheForTest,
|
||||
} from "../services/exchange-rates";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
@@ -8,6 +12,11 @@ global.fetch = mockFetch;
|
||||
describe("exchange-rates", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
// The rate cache is module-level and persists across tests. Without this
|
||||
// reset, the first test to populate "today" would short-circuit every
|
||||
// later mockFetch resolution (cache hit), making assertions order-dependent
|
||||
// and asserting stale rates. Reset it so each test gets a clean fetch.
|
||||
__resetRateCacheForTest();
|
||||
});
|
||||
|
||||
describe("toCzk", () => {
|
||||
@@ -43,6 +52,21 @@ describe("exchange-rates", () => {
|
||||
const result = await toCzk(100, "EUR");
|
||||
expect(result).toBe(2500);
|
||||
});
|
||||
|
||||
it("honours amount != 1 (currency quoted per 100 units)", async () => {
|
||||
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
|
||||
// is for `amount` units, so the per-unit rate is rate/amount. Here the
|
||||
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
|
||||
// If the service ignored `amount` it would compute 100*250 = 25000.
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||
}),
|
||||
});
|
||||
const result = await toCzk(100, "HUF");
|
||||
expect(result).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRate", () => {
|
||||
@@ -60,5 +84,17 @@ describe("exchange-rates", () => {
|
||||
});
|
||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
|
||||
it("returns the per-unit rate for amount != 1", async () => {
|
||||
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||
}),
|
||||
});
|
||||
const result = await getRate("HUF");
|
||||
expect(result).toBe(2.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,31 @@ export async function buildApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
export function extractCookie(response: any, name: string): string | undefined {
|
||||
/**
|
||||
* Minimal structural shape of the only thing extractCookie needs from a
|
||||
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
|
||||
* (from `app.inject`) and a plain supertest response without coupling to either.
|
||||
*/
|
||||
interface ResponseWithCookies {
|
||||
headers: Record<string, string | string[] | number | undefined>;
|
||||
}
|
||||
|
||||
export function extractCookie(
|
||||
response: ResponseWithCookies,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
for (const c of arr) {
|
||||
if (typeof c !== "string") continue;
|
||||
if (c.startsWith(`${name}=`)) {
|
||||
return c.split(";")[0].split("=")[1];
|
||||
// The value is everything from the first "=" up to the first ";".
|
||||
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
|
||||
// padding) is not truncated — `split("=")[1]` would drop everything after
|
||||
// the second "=".
|
||||
const pair = c.split(";")[0];
|
||||
return pair.slice(pair.indexOf("=") + 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
50
src/__tests__/invoice-alerts.test.ts
Normal file
50
src/__tests__/invoice-alerts.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeAlertWindow } from "../services/invoice-alerts";
|
||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||
|
||||
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
|
||||
// a Date used in a WHERE filter to its UTC date part, so the alert window
|
||||
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
|
||||
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
|
||||
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
|
||||
// advance alert (due == today+3) could then never fire. All assertions below
|
||||
// are timezone-independent (inputs are built from local components).
|
||||
|
||||
describe("utcMidnightOfLocalDay", () => {
|
||||
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
|
||||
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
|
||||
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
|
||||
// exactly what Prisma would truncate a bare Date to.
|
||||
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
|
||||
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
|
||||
Date.UTC(2098, 6, 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns UTC midnight of the local day for a mid-day instant", () => {
|
||||
const noon = new Date(2098, 0, 15, 12, 0, 0);
|
||||
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAlertWindow", () => {
|
||||
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
|
||||
// 00:30 local — the window where the old local-midnight computation
|
||||
// produced yesterday's UTC date and the whole window slid a day early.
|
||||
const now = new Date(2098, 6, 1, 0, 30, 0);
|
||||
const w = computeAlertWindow(now);
|
||||
|
||||
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
|
||||
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
|
||||
expect(w.todayStr).toBe("2098-07-01");
|
||||
expect(w.in3daysStr).toBe("2098-07-04");
|
||||
});
|
||||
|
||||
it("rolls the +3-day bound over a month boundary", () => {
|
||||
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
|
||||
const w = computeAlertWindow(now);
|
||||
|
||||
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
|
||||
expect(w.in3daysStr).toBe("2098-02-02");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
invoiceTotalWithVat,
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
getInvoice,
|
||||
listInvoices,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../schemas/invoices.schema";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
|
||||
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
|
||||
// for `Number(decimal)`) is actually caught.
|
||||
|
||||
const dec = (n: number | string) => new Prisma.Decimal(n);
|
||||
|
||||
// Helper so each test reads as a small declarative table of lines.
|
||||
function buildInvoice(opts: {
|
||||
applyVat: boolean;
|
||||
vatRate: number | null;
|
||||
currency?: string;
|
||||
items: Array<{
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
vat_rate: number | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
apply_vat: opts.applyVat,
|
||||
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
|
||||
currency: opts.currency ?? "CZK",
|
||||
invoice_items: opts.items.map((i) => ({
|
||||
quantity: i.quantity === null ? null : dec(i.quantity),
|
||||
unit_price: i.unit_price === null ? null : dec(i.unit_price),
|
||||
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("invoiceTotalWithVat", () => {
|
||||
it("calculates subtotal without VAT when apply_vat is false", () => {
|
||||
const inv = {
|
||||
apply_vat: false,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 2 },
|
||||
unit_price: { toNumber: () => 100 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const inv = buildInvoice({
|
||||
applyVat: false,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||
});
|
||||
|
||||
@@ -24,32 +61,298 @@ describe("invoiceTotalWithVat", () => {
|
||||
// Total VAT = 7.00 * 3 = 21.00
|
||||
// Subtotal = 33.33 * 3 = 99.99
|
||||
// Total = 99.99 + 21.00 = 120.99
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: Array.from({ length: 3 }, () => ({
|
||||
quantity: { toNumber: () => 1 },
|
||||
unit_price: { toNumber: () => 33.33 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: Array.from({ length: 3 }, () => ({
|
||||
quantity: 1,
|
||||
unit_price: 33.33,
|
||||
vat_rate: 21,
|
||||
})),
|
||||
};
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
||||
});
|
||||
|
||||
it("handles null quantity and unit_price gracefully", () => {
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 0 },
|
||||
unit_price: { toNumber: () => 0 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
|
||||
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
|
||||
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
|
||||
// line proves the null line contributes nothing while the rest still totals.
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: null, unit_price: null, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
|
||||
],
|
||||
};
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(121);
|
||||
});
|
||||
|
||||
it("returns 0 when every line is null", () => {
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: null, unit_price: null, vat_rate: null }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||
});
|
||||
|
||||
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
|
||||
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
|
||||
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
|
||||
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
|
||||
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
|
||||
// Subtotal = 350, VAT = 63, Total = 413
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 15,
|
||||
items: [
|
||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 0 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(413);
|
||||
});
|
||||
|
||||
it("handles a negative (discount) line correctly", () => {
|
||||
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
|
||||
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
|
||||
// Subtotal = 800, VAT = 168, Total = 968
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: -200, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
|
||||
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
|
||||
// which STRIPS unknown keys — so a field missing from the schema is silently
|
||||
// dropped before the service ever sees it, while the client still gets a
|
||||
// success response. This block mirrors that exact flow against the real DB.
|
||||
|
||||
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("persists an edited billing_text (the schema must not strip it)", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Původní text" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
|
||||
const parsed = UpdateInvoiceSchema.parse({
|
||||
billing_text: "Fakturujeme Vám dle objednávky č. 123",
|
||||
});
|
||||
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
|
||||
const res = await updateInvoice(draft.id, parsed);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
});
|
||||
|
||||
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Text na PDF" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
|
||||
);
|
||||
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Text na PDF");
|
||||
|
||||
// Explicit null -> cleared (the strFields path maps falsy -> null).
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ billing_text: null }),
|
||||
);
|
||||
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("creates, returns and replaces CZ/EN sections in position order", async () => {
|
||||
const parsed = CreateInvoiceSchema.parse({
|
||||
billing_text: "Sekce test",
|
||||
sections: [
|
||||
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
|
||||
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
|
||||
],
|
||||
});
|
||||
const draft = await createInvoice(parsed);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
const detail = await getInvoice(draft.id);
|
||||
expect(detail?.sections?.length).toBe(2);
|
||||
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
|
||||
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
|
||||
expect(detail?.sections?.[0].position).toBe(0);
|
||||
expect(detail?.sections?.[1].position).toBe(1);
|
||||
|
||||
// Full-replace on update (same model as items).
|
||||
const upd = UpdateInvoiceSchema.parse({
|
||||
sections: [
|
||||
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
|
||||
],
|
||||
});
|
||||
const res = await updateInvoice(draft.id, upd);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Jediná");
|
||||
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
|
||||
});
|
||||
|
||||
it("sections survive an items-only update untouched (absent = keep)", async () => {
|
||||
const draft = await createInvoice(
|
||||
CreateInvoiceSchema.parse({
|
||||
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
|
||||
}),
|
||||
);
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({
|
||||
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const after = await getInvoice(draft.id);
|
||||
expect(after?.sections?.length).toBe(1);
|
||||
expect(after?.sections?.[0].title_cz).toBe("Drží");
|
||||
expect(after?.items?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("silently strips the dropped `notes` key from legacy payloads", async () => {
|
||||
// The printed-notes column was dropped (notes are internal-only now) —
|
||||
// a legacy client still sending `notes` must not 500 or write anything.
|
||||
const parsedCreate = CreateInvoiceSchema.parse({
|
||||
notes: "<p>stará poznámka</p>",
|
||||
internal_notes: "<p>interní</p>",
|
||||
});
|
||||
expect("notes" in parsedCreate).toBe(false);
|
||||
|
||||
const draft = await createInvoice(parsedCreate);
|
||||
invoiceIds.push(draft.id);
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.internal_notes).toBe("<p>interní</p>");
|
||||
|
||||
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
|
||||
expect("notes" in parsedUpdate).toBe(false);
|
||||
const res = await updateInvoice(draft.id, parsedUpdate);
|
||||
expect("error" in res).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Month filter boundaries — issue_date is @db.Date */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
|
||||
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
|
||||
// shifted the window a day back — the list/totals included the previous
|
||||
// month's last day and DROPPED invoices issued on the selected month's LAST
|
||||
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
|
||||
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
|
||||
// 2098 dates so totals can be asserted exactly without colliding with other
|
||||
// suites' rows.
|
||||
|
||||
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
|
||||
const cleanup = async () => {
|
||||
// invoice_items cascade on invoice delete.
|
||||
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
|
||||
};
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterEach(cleanup);
|
||||
|
||||
const listParams = {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
skip: 0,
|
||||
sort: "id",
|
||||
order: "asc" as const,
|
||||
search: "",
|
||||
};
|
||||
|
||||
async function createBoundaryFixtures() {
|
||||
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
|
||||
// status filter so they appear in the list/totals like any invoice.
|
||||
const lastDayJan = await createInvoice({
|
||||
status: "draft",
|
||||
issue_date: "2098-01-31",
|
||||
currency: "TST",
|
||||
vat_rate: 21,
|
||||
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
|
||||
});
|
||||
const firstDayFeb = await createInvoice({
|
||||
status: "draft",
|
||||
issue_date: "2098-02-01",
|
||||
currency: "TST",
|
||||
vat_rate: 21,
|
||||
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
|
||||
});
|
||||
return { lastDayJan, firstDayFeb };
|
||||
}
|
||||
|
||||
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
|
||||
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
|
||||
|
||||
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
|
||||
const janIds = jan.data.map((i) => i.id);
|
||||
expect(janIds).toContain(lastDayJan.id);
|
||||
expect(janIds).not.toContain(firstDayFeb.id);
|
||||
|
||||
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
|
||||
const febIds = feb.data.map((i) => i.id);
|
||||
expect(febIds).toContain(firstDayFeb.id);
|
||||
expect(febIds).not.toContain(lastDayJan.id);
|
||||
});
|
||||
|
||||
it("per-currency totals include the month's last day exactly once", async () => {
|
||||
await createBoundaryFixtures();
|
||||
|
||||
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
|
||||
const janTst = jan.totals.find((t) => t.currency === "TST");
|
||||
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
|
||||
// Feb 1 invoice (242) is NOT pulled into January.
|
||||
expect(janTst?.amount).toBe(121);
|
||||
|
||||
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
|
||||
const febTst = feb.totals.find((t) => t.currency === "TST");
|
||||
expect(febTst?.amount).toBe(242);
|
||||
});
|
||||
});
|
||||
|
||||
1288
src/__tests__/issued-orders.test.ts
Normal file
1288
src/__tests__/issued-orders.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
356
src/__tests__/manual-create.test.ts
Normal file
356
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createProject } from "../services/projects.service";
|
||||
import {
|
||||
createOrder,
|
||||
createOrderFromQuotation,
|
||||
deleteOrder,
|
||||
} from "../services/orders.service";
|
||||
import { previewProjectNumber } from "../services/numbering.service";
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
const createdProjectIds: number[] = [];
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdQuotationIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdProjectIds)
|
||||
await prisma.projects.deleteMany({ where: { id } });
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
createdProjectIds.length = 0;
|
||||
createdOrderIds.length = 0;
|
||||
createdQuotationIds.length = 0;
|
||||
// Orders and projects now draw from SEPARATE sequences — reset both.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the current `last_number` of a (type, current-year) sequence row, or 0
|
||||
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
|
||||
* prove each generator advances its OWN row by exactly 1.
|
||||
*/
|
||||
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
|
||||
const row = await prisma.number_sequences.findFirst({
|
||||
where: { type, year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
return row?.last_number ?? 0;
|
||||
}
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
|
||||
* passed the test when a create failed) while still narrowing the union via the
|
||||
* `in` operator so the success-branch fields stay typed.
|
||||
*/
|
||||
function failResult(res: unknown): never {
|
||||
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||
}
|
||||
|
||||
describe("createOrder (manual, optional linked project)", () => {
|
||||
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
|
||||
// Capture BOTH sequence counters before the create so we can prove each
|
||||
// generator advanced its OWN row (not that the project consumed the shared
|
||||
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
|
||||
// pass even if the project drew from `shared` — the deltas catch that.
|
||||
const sharedBefore = await seqLastNumber("shared");
|
||||
const projectBefore = await seqLastNumber("project");
|
||||
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Ruční zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
// The auto-created project now draws from the dedicated project sequence,
|
||||
// so its number is distinct from the order's shared number. They relate
|
||||
// only via the order_id FK, not by sharing a number.
|
||||
expect(project?.project_number).toBeTruthy();
|
||||
expect(project?.project_number).not.toBe(res.order_number);
|
||||
expect(project?.order_id).toBe(res.id);
|
||||
|
||||
// Sequence isolation: the order consumed exactly one `shared` number and
|
||||
// the project consumed exactly one `project` number — each generator
|
||||
// advanced its OWN row by 1. A regression where the project draws from the
|
||||
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
|
||||
const sharedAfter = await seqLastNumber("shared");
|
||||
const projectAfter = await seqLastNumber("project");
|
||||
expect(sharedAfter - sharedBefore).toBe(1);
|
||||
expect(projectAfter - projectBefore).toBe(1);
|
||||
});
|
||||
|
||||
it("creates only the order when create_project=false", async () => {
|
||||
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
expect(res.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
|
||||
// Seed a minimal quotation (no items/sections needed — createOrderFrom
|
||||
// Quotation copies whatever is present and both arrays may be empty).
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FROMQ-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: quotation.id,
|
||||
customerOrderNumber: "PO-FROMQ",
|
||||
});
|
||||
if (!("data" in res) || !res.data) failResult(res);
|
||||
const orderId = res.data.order_id;
|
||||
createdOrderIds.push(orderId);
|
||||
|
||||
const order = await prisma.orders.findUnique({ where: { id: orderId } });
|
||||
const project = await prisma.projects.findFirst({
|
||||
where: { order_id: orderId },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
|
||||
// Primary flow: the auto-created project must get a non-empty number from
|
||||
// the dedicated `project` sequence (code 73), DISTINCT from the order's
|
||||
// shared number (code 71). Pre-split they shared one number.
|
||||
expect(project).toBeTruthy();
|
||||
expect(project?.project_number).toBeTruthy();
|
||||
expect(order?.order_number).toBeTruthy();
|
||||
expect(project?.project_number).not.toBe(order?.order_number);
|
||||
expect(project?.order_id).toBe(orderId);
|
||||
});
|
||||
|
||||
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
||||
const draft = await prisma.quotations.create({
|
||||
data: { status: "draft", currency: "CZK", language: "cs" },
|
||||
});
|
||||
createdQuotationIds.push(draft.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "draft"');
|
||||
}
|
||||
// The offer is untouched — still a numberless draft.
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
expect(row!.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
||||
const invalidated = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-INVAL-${Date.now()}`,
|
||||
status: "invalidated",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(invalidated.id);
|
||||
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: invalidated.id,
|
||||
});
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "invalidated"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder reverts the source offer (stuck-ordered regression)", () => {
|
||||
it("returns the linked quotation to active with order_id cleared", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-REVERT-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: quotation.id });
|
||||
if (!("data" in res) || !res.data) failResult(res);
|
||||
const project = await prisma.projects.findFirst({
|
||||
where: { order_id: res.data.order_id },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
|
||||
const afterCreate = await prisma.quotations.findUnique({
|
||||
where: { id: quotation.id },
|
||||
});
|
||||
expect(afterCreate!.status).toBe("ordered");
|
||||
expect(afterCreate!.order_id).toBe(res.data.order_id);
|
||||
|
||||
const del = await deleteOrder(res.data.order_id);
|
||||
if ("error" in del) failResult(del);
|
||||
|
||||
// The offer must be orderable again — `ordered` with no order_id is a
|
||||
// dead end (the transition table only allows ordered -> invalidated).
|
||||
const afterDelete = await prisma.quotations.findUnique({
|
||||
where: { id: quotation.id },
|
||||
});
|
||||
expect(afterDelete!.order_id).toBeNull();
|
||||
expect(afterDelete!.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder releases the project number (release regression)", () => {
|
||||
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
|
||||
// create is the number createOrder will consume for the project.
|
||||
const previewBefore = await previewProjectNumber();
|
||||
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Smazatelná zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
const orderId = res.id!;
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
expect(project?.project_number).toBe(previewBefore);
|
||||
|
||||
// After the order delete, the project row is gone AND its number must have
|
||||
// been released (decremented) so the preview returns the freed number — not
|
||||
// a permanent gap. This is the fix #1 regression guard.
|
||||
const del = await deleteOrder(orderId);
|
||||
if ("error" in del) failResult(del);
|
||||
|
||||
const gone = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
expect(gone).toBeNull();
|
||||
|
||||
const previewAfter = await previewProjectNumber();
|
||||
expect(previewAfter).toBe(previewBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("separate order vs project sequences (coherence)", () => {
|
||||
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||
if (!("id" in o1)) failResult(o1);
|
||||
createdOrderIds.push(o1.id!);
|
||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||
|
||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||
if (!("project_number" in p)) failResult(p);
|
||||
createdProjectIds.push(p.id);
|
||||
|
||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||
if (!("id" in o2)) failResult(o2);
|
||||
createdOrderIds.push(o2.id!);
|
||||
|
||||
// All three numbers must be present (non-null) AND distinct. Orders draw
|
||||
// from the `shared` sequence (code 71); projects from the dedicated
|
||||
// `project` sequence (code 73), so they never collide.
|
||||
expect(o1.order_number).toBeTruthy();
|
||||
expect(p.project_number).toBeTruthy();
|
||||
expect(o2.order_number).toBeTruthy();
|
||||
expect(
|
||||
new Set([o1.order_number, p.project_number, o2.order_number]).size,
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProject (manual, standalone)", () => {
|
||||
it("assigns a project number and is not linked to an order", async () => {
|
||||
const result = await createProject({
|
||||
name: "Test projekt",
|
||||
status: "aktivni",
|
||||
});
|
||||
if (!("project_number" in result)) failResult(result);
|
||||
createdProjectIds.push(result.id);
|
||||
expect(typeof result.project_number).toBe("string");
|
||||
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||
expect(result.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("gives consecutive standalone projects distinct numbers", async () => {
|
||||
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
||||
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
||||
if ("project_number" in a) createdProjectIds.push(a.id);
|
||||
if ("project_number" in b) createdProjectIds.push(b.id);
|
||||
expect("project_number" in a && "project_number" in b).toBe(true);
|
||||
if ("project_number" in a && "project_number" in b) {
|
||||
expect(a.project_number).not.toBe(b.project_number);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrder with price line item + attachment", () => {
|
||||
it("stores a single line item with the entered price", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
create_project: false,
|
||||
items: [
|
||||
{
|
||||
description: "Práce",
|
||||
quantity: 1,
|
||||
unit_price: 12345,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
const items = await prisma.order_items.findMany({
|
||||
where: { order_id: res.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(12345);
|
||||
});
|
||||
|
||||
it("stores an uploaded PO attachment with the exact bytes", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
|
||||
const res = await createOrder(
|
||||
{ ...baseOrder, create_project: false },
|
||||
buf,
|
||||
"po.pdf",
|
||||
);
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: res.id },
|
||||
select: { attachment_name: true, attachment_data: true },
|
||||
});
|
||||
expect(order?.attachment_name).toBe("po.pdf");
|
||||
// Verify the stored content/size, not just truthiness: round-trip the
|
||||
// bytes and compare length + value against what we uploaded.
|
||||
expect(order?.attachment_data).toBeTruthy();
|
||||
const stored = Buffer.from(order!.attachment_data!);
|
||||
expect(stored.length).toBe(buf.length);
|
||||
expect(stored.equals(buf)).toBe(true);
|
||||
});
|
||||
});
|
||||
81
src/__tests__/nas-document-manager.test.ts
Normal file
81
src/__tests__/nas-document-manager.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasDocumentManager } from "../services/nas-financials-manager";
|
||||
|
||||
/**
|
||||
* Generic NAS archival manager backing both the invoices NAS (NAS_INVOICES) and
|
||||
* the orders NAS (NAS_ORDERS). Uses an isolated temp dir via the constructor
|
||||
* seam so it is deterministic and passes even where the real NAS (Z:) is not
|
||||
* mounted. Layout: {base}/Vydané/YYYY/MM/<n>.pdf — diacritics preserved.
|
||||
*/
|
||||
describe("NasDocumentManager issued-PDF round-trip", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasDocumentManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-doc-"));
|
||||
nas = new NasDocumentManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("saves an issued PDF under Vydané/YYYY/MM/<number>.pdf and reads it back", () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake order pdf bytes", "utf8");
|
||||
const rel = nas.saveIssuedPdf("25P0001", 2026, 6, buf);
|
||||
|
||||
expect(rel).toBe("Vydané/2026/06/25P0001.pdf");
|
||||
const onDisk = path.join(tmpBase, "Vydané", "2026", "06", "25P0001.pdf");
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
const read = nas.readIssued(rel!);
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.fileName).toBe("25P0001.pdf");
|
||||
expect(read!.data.equals(buf)).toBe(true);
|
||||
});
|
||||
|
||||
it("cleanIssued removes a previously-saved PDF across year/month folders", () => {
|
||||
const buf = Buffer.from("pdf", "utf8");
|
||||
const rel = nas.saveIssuedPdf("25P0002", 2026, 6, buf);
|
||||
expect(nas.readIssued(rel!)).not.toBeNull();
|
||||
|
||||
nas.cleanIssued("25P0002");
|
||||
expect(nas.readIssued(rel!)).toBeNull();
|
||||
});
|
||||
|
||||
it("an unconfigured (empty basePath) manager is not configured and saves nothing", () => {
|
||||
const blank = new NasDocumentManager("");
|
||||
expect(blank.isConfigured()).toBe(false);
|
||||
expect(
|
||||
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("re-saving the same number OVERWRITES in place — never a _1 duplicate", () => {
|
||||
const first = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v1"));
|
||||
const second = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v2"));
|
||||
|
||||
// Same canonical path both times, newest content wins.
|
||||
expect(second).toBe(first);
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
expect(fs.readdirSync(dir)).toEqual(["25P0004.pdf"]);
|
||||
expect(nas.readIssued(second!)!.data.toString()).toBe("v2");
|
||||
});
|
||||
|
||||
it("cleanIssued also sweeps stale _N duplicates left by the old racy save", () => {
|
||||
nas.saveIssuedPdf("25P0005", 2026, 6, Buffer.from("canonical"));
|
||||
// Simulate duplicates the pre-fix uniquePath save created during races.
|
||||
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_1.pdf"), "stale");
|
||||
fs.writeFileSync(path.join(dir, "25P0005_2.pdf"), "stale");
|
||||
// A DIFFERENT number must not be touched by the suffix match.
|
||||
nas.saveIssuedPdf("25P0006", 2026, 6, Buffer.from("other"));
|
||||
|
||||
nas.cleanIssued("25P0005");
|
||||
|
||||
expect(fs.readdirSync(dir).sort()).toEqual(["25P0006.pdf"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
describe("NasFileManager path traversal", () => {
|
||||
@@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Positive-path coverage: a legitimate (non-traversal) path inside a real
|
||||
* project folder is ACCEPTED — it passes resolveProjectPath validation and
|
||||
* reaches the underlying fs op (success → null). Without this, a
|
||||
* "reject-everything" regression in the traversal guard would still pass every
|
||||
* rejection test above. Uses the constructor basePath seam + a temp dir so it
|
||||
* is deterministic even where the real NAS (Z:) is not mounted.
|
||||
*/
|
||||
describe("NasFileManager accepts legitimate paths", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
const NUM = "29990001";
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
// Create a real project folder <number>_<name> so findProjectFolder resolves.
|
||||
nas.createProjectFolder(NUM, "Test");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
|
||||
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
|
||||
|
||||
const result = await nas.deleteItem(NUM, "doc.pdf");
|
||||
// null = success: the path was accepted and the file actually removed.
|
||||
expect(result).toBeNull();
|
||||
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||
|
||||
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
|
||||
expect(result).toBeNull();
|
||||
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
|
||||
|
||||
const result = await nas.deleteItem(NUM, "../keep.pdf");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
// The guard rejected the path before any fs op — nothing was removed.
|
||||
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
|
||||
// With a real project folder, the source `from.pdf` resolves cleanly, so
|
||||
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
|
||||
// the source must stay put (no move outside the project folder).
|
||||
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||
|
||||
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
|
||||
// Nothing escaped the project folder.
|
||||
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
106
src/__tests__/nas-project-folder.test.ts
Normal file
106
src/__tests__/nas-project-folder.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
/**
|
||||
* Folder auto-creation primitive used by both the manual project-create path
|
||||
* (projects.service createProject) and the order→project paths (orders.service
|
||||
* createOrder / createOrderFromQuotation). Uses an isolated temp dir via the
|
||||
* constructor seam so it is deterministic and passes even where the real NAS
|
||||
* (Z:) is not mounted.
|
||||
*/
|
||||
describe("NasFileManager.createProjectFolder", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a <number>_<name> folder for a new project", () => {
|
||||
const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly");
|
||||
expect(ok).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent — a second call does not create a duplicate", () => {
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
const matches = fs
|
||||
.readdirSync(tmpBase)
|
||||
.filter((e) => e.startsWith("26710002_"));
|
||||
expect(matches).toEqual(["26710002_Hala"]);
|
||||
});
|
||||
|
||||
it("preserves Czech diacritics (no transliteration)", () => {
|
||||
expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips illegal characters and collapses spaces", () => {
|
||||
expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an empty name without crashing (trailing underscore)", () => {
|
||||
expect(nas.createProjectFolder("26710005", "")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false and creates nothing when the NAS base is not mounted", () => {
|
||||
const missing = new NasFileManager(
|
||||
path.join(tmpBase, "does-not-exist-subdir"),
|
||||
);
|
||||
expect(missing.createProjectFolder("26710006", "X")).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Folder deletion primitive used by the "delete folder" checkbox on both the
|
||||
* order delete (orders.service deleteOrder) and project delete (projects.service
|
||||
* deleteProject) paths. Matches the project by number prefix.
|
||||
*/
|
||||
describe("NasFileManager.deleteProjectFolder", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-del-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes an existing project folder (matched by number prefix)", async () => {
|
||||
nas.createProjectFolder("26710010", "Hala");
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(true);
|
||||
const ok = await nas.deleteProjectFolder("26710010");
|
||||
expect(ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op (returns true) when no folder exists for the number", async () => {
|
||||
expect(await nas.deleteProjectFolder("26710011")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the NAS base is not mounted", async () => {
|
||||
const missing = new NasFileManager(path.join(tmpBase, "nope"));
|
||||
expect(await missing.deleteProjectFolder("26710012")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,59 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
previewSharedNumber,
|
||||
isSharedNumberTaken,
|
||||
previewOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
generateProjectNumber,
|
||||
previewProjectNumber,
|
||||
isProjectNumberTaken,
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
/**
|
||||
* Assert two consecutive document numbers (a) keep an identical format / year
|
||||
* prefix and (b) increment by exactly one — without hard-coding which
|
||||
* `company_settings` pattern is configured.
|
||||
*
|
||||
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
|
||||
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
|
||||
* of the two strings (the unchanged format/year portion) and parse the
|
||||
* remaining differing suffixes as integers — these are the rendered sequences.
|
||||
*/
|
||||
function assertStrictIncrement(num1: string, num2: string) {
|
||||
expect(num1).not.toBe(num2);
|
||||
expect(num2.length).toBe(num1.length); // same width ⇒ same format
|
||||
|
||||
let i = 0;
|
||||
while (i < num1.length && num1[i] === num2[i]) i++;
|
||||
const commonPrefix = num1.slice(0, i);
|
||||
// The format/year portion is shared and non-empty (the numbers don't differ
|
||||
// from the very first character).
|
||||
expect(commonPrefix.length).toBeGreaterThan(0);
|
||||
|
||||
const seq1 = Number(num1.slice(i));
|
||||
const seq2 = Number(num2.slice(i));
|
||||
expect(Number.isNaN(seq1)).toBe(false);
|
||||
expect(Number.isNaN(seq2)).toBe(false);
|
||||
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the (type, year) sequence row to a high `last_number` so the next
|
||||
* generated numbers land far above any real order/quotation in the test DB —
|
||||
* the generator's skip-taken retry then never fires and the strict +1
|
||||
* assertion is deterministic.
|
||||
*/
|
||||
async function seedSequence(type: string, lastNumber: number) {
|
||||
await prisma.number_sequences.deleteMany({ where: { type } });
|
||||
await prisma.number_sequences.create({
|
||||
data: { type, year: YEAR, last_number: lastNumber },
|
||||
});
|
||||
}
|
||||
|
||||
describe("generateSharedNumber", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
@@ -20,10 +70,94 @@ describe("generateSharedNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
// Seed high so neither generated number collides with a real order/project
|
||||
// → the skip-taken retry never fires and the increment is strictly +1.
|
||||
await seedSequence("shared", 900000);
|
||||
const num1 = await generateSharedNumber();
|
||||
const num2 = await generateSharedNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateProjectNumber", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
|
||||
});
|
||||
|
||||
it("returns a non-empty string", async () => {
|
||||
const num = await generateProjectNumber();
|
||||
expect(typeof num).toBe("string");
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
// Seed high so neither generated number collides with a real project →
|
||||
// the skip-taken retry never fires and the increment is strictly +1.
|
||||
await seedSequence("project", 900000);
|
||||
const num1 = await generateProjectNumber();
|
||||
const num2 = await generateProjectNumber();
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
|
||||
it("previewProjectNumber returns a number not yet taken by a project", async () => {
|
||||
const preview = await previewProjectNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(preview.length).toBeGreaterThan(0);
|
||||
expect(await isProjectNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-sequence isolation (shared vs project)", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => {
|
||||
// Seed the two sequences to DISTINCT known highs so neither generated
|
||||
// number collides with a real row (skip-taken retry never fires) and a
|
||||
// cross-contamination bug is unambiguous.
|
||||
await seedSequence("shared", 900000);
|
||||
await seedSequence("project", 500000);
|
||||
|
||||
await generateSharedNumber();
|
||||
// After a shared allocation only the shared row may have moved.
|
||||
let shared = await prisma.number_sequences.findFirst({
|
||||
where: { type: "shared", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
let project = await prisma.number_sequences.findFirst({
|
||||
where: { type: "project", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
expect(shared?.last_number).toBe(900001);
|
||||
expect(project?.last_number).toBe(500000);
|
||||
|
||||
await generateProjectNumber();
|
||||
// After a project allocation only the project row may have moved; the
|
||||
// shared row must still sit at 900001.
|
||||
shared = await prisma.number_sequences.findFirst({
|
||||
where: { type: "shared", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
project = await prisma.number_sequences.findFirst({
|
||||
where: { type: "project", year: YEAR },
|
||||
select: { last_number: true },
|
||||
});
|
||||
expect(shared?.last_number).toBe(900001);
|
||||
expect(project?.last_number).toBe(500001);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,9 +176,103 @@ describe("generateOfferNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
await seedSequence("offer", 900000);
|
||||
const num1 = await generateOfferNumber();
|
||||
const num2 = await generateOfferNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewSharedNumber", () => {
|
||||
let createdProjectId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdProjectId !== null) {
|
||||
await prisma.projects
|
||||
.delete({ where: { id: createdProjectId } })
|
||||
.catch(() => {});
|
||||
createdProjectId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by an order or project", async () => {
|
||||
const preview = await previewSharedNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isSharedNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Seed the counter HIGH so the preview lands far above any real
|
||||
// order/project in the test DB. Without this, a real row could already
|
||||
// hold the first sequence number — making the create collide on the unique
|
||||
// column, or making `second !== first` pass for the wrong reason (because
|
||||
// the counter happened to already point past `first`).
|
||||
await seedSequence("shared", 900000);
|
||||
const first = await previewSharedNumber();
|
||||
// Preconditions: the seeded preview really is free (so the create below is
|
||||
// safe) and is exactly what the generator would assign next.
|
||||
expect(await isSharedNumberTaken(first)).toBe(false);
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||
});
|
||||
createdProjectId = project.id;
|
||||
|
||||
// The counter still lags behind (no row was consumed), but `first` is now
|
||||
// taken — the preview must advance past it instead of re-offering it.
|
||||
const second = await previewSharedNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isSharedNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewOfferNumber", () => {
|
||||
let createdQuotationId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdQuotationId !== null) {
|
||||
await prisma.quotations
|
||||
.delete({ where: { id: createdQuotationId } })
|
||||
.catch(() => {});
|
||||
createdQuotationId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by a quotation", async () => {
|
||||
const preview = await previewOfferNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isOfferNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Seed HIGH so the preview lands above any real quotation in the test DB
|
||||
// (see the shared-number test for the rationale): the create can't collide
|
||||
// and the skip-advance is genuinely exercised rather than passing because a
|
||||
// real row already advanced the preview.
|
||||
await seedSequence("offer", 900000);
|
||||
const first = await previewOfferNumber();
|
||||
expect(await isOfferNumberTaken(first)).toBe(false);
|
||||
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: { quotation_number: first },
|
||||
});
|
||||
createdQuotationId = quotation.id;
|
||||
|
||||
// The counter still lags, but `first` is now taken — the preview must
|
||||
// advance past it instead of re-offering it.
|
||||
const second = await previewOfferNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isOfferNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
276
src/__tests__/offer-invoice-totals.test.ts
Normal file
276
src/__tests__/offer-invoice-totals.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices";
|
||||
|
||||
// Per-currency total aggregation for the offers, issued-invoices and
|
||||
// received-invoices lists. These hit the real `app_test` DB via the service
|
||||
// layer (suite convention) and prove the totals fns sum each document's TOTAL
|
||||
// per currency across the WHOLE filtered set, and that the filtering `where`
|
||||
// (search / month-year / status) is respected — mirroring order-totals.test.ts.
|
||||
//
|
||||
// For invoices a far-future month/year is used so seeded/other-test rows can't
|
||||
// fall in the window and skew the deterministic sums. Offers have NO date
|
||||
// filter, so they are isolated by a unique `project_code` searched on instead.
|
||||
// Received invoices store amount as GROSS (VAT-inclusive), summed directly.
|
||||
|
||||
const STATS_YEAR = 2097;
|
||||
const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01)
|
||||
|
||||
// Unique marker so the offers test (which has no date filter) can scope its
|
||||
// aggregation to only the rows it created via a `search` on project_code.
|
||||
const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`;
|
||||
|
||||
const createdOfferIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
const createdReceivedIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOfferIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
for (const id of createdReceivedIds)
|
||||
await prisma.received_invoices.deleteMany({ where: { id } });
|
||||
createdOfferIds.length = 0;
|
||||
createdInvoiceIds.length = 0;
|
||||
createdReceivedIds.length = 0;
|
||||
// Finalized offers/invoices consume their sequences — reset so we don't leak
|
||||
// a consumed number into sibling test files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["offer", "invoice"] } },
|
||||
});
|
||||
});
|
||||
|
||||
function amountFor(
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined {
|
||||
return totals.find((t) => t.currency === currency)?.amount;
|
||||
}
|
||||
|
||||
describe("getOfferTotals per-currency aggregation", () => {
|
||||
it("sums offer NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
|
||||
// not tax documents, no VAT anywhere).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOffer({
|
||||
status: "draft", // draft so no offer number is consumed
|
||||
currency,
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||
createdOfferIds.push(res.id);
|
||||
return res.id;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a status filter narrows the result", async () => {
|
||||
const mk = async (status: string) => {
|
||||
const res = await createOffer({
|
||||
status,
|
||||
currency: "CZK",
|
||||
project_code: OFFER_MARKER,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||
createdOfferIds.push(res.id);
|
||||
return res.id;
|
||||
};
|
||||
|
||||
// Two drafts + one invalidated, all sharing the marker.
|
||||
await mk("draft");
|
||||
await mk("draft");
|
||||
await mk("invalidated");
|
||||
|
||||
// Marker only: all three count -> 3 x 1000 = 3000 (net).
|
||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||
expect(amountFor(all.totals, "CZK")).toBe(3000);
|
||||
|
||||
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
|
||||
const onlyDraft = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "draft",
|
||||
});
|
||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Invalidated alone -> 1000.
|
||||
const onlyInvalid = await getOfferTotals({
|
||||
search: OFFER_MARKER,
|
||||
status: "invalidated",
|
||||
});
|
||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => {
|
||||
// issue_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeInvoiceTotals) but with a single line per invoice
|
||||
// here the result matches the whole-subtotal math.
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
|
||||
const mk = async (
|
||||
currency: string,
|
||||
qty: number,
|
||||
price: number,
|
||||
status = "draft",
|
||||
) => {
|
||||
const inv = await createInvoice({
|
||||
status,
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
createdInvoiceIds.push(inv.id);
|
||||
return inv.id;
|
||||
};
|
||||
|
||||
it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// CZK #1: 2 x 1000 @21% = 2420
|
||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one issued (both 1210). Next month: one draft.
|
||||
await mk("CZK", 1, 1000, "draft");
|
||||
// An already-issued invoice numbers immediately; that's fine for the sum.
|
||||
const issued = await createInvoice({
|
||||
status: "issued",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
createdInvoiceIds.push(issued.id);
|
||||
|
||||
const next = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: nextDateStr,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
createdInvoiceIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
const whole = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'issued' in the target month -> 1210.
|
||||
const onlyIssued = await getInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "issued",
|
||||
});
|
||||
expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one invoice there -> 1210.
|
||||
const nextMonth = await getInvoiceListTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("received-invoices list-totals per-currency aggregation (GROSS)", () => {
|
||||
// No service create for received invoices — rows are written directly. The
|
||||
// list/totals `where` filters by the month/year COLUMNS (not issue_date).
|
||||
// amount is GROSS, summed directly per currency.
|
||||
const mkReceived = async (currency: string, amount: number) => {
|
||||
const row = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
supplier_name: "Totals Test Supplier",
|
||||
amount,
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
createdReceivedIds.push(row.id);
|
||||
return row.id;
|
||||
};
|
||||
|
||||
it("sums GROSS amount per currency over the full filtered set", async () => {
|
||||
// Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is.
|
||||
await mkReceived("CZK", 2420);
|
||||
await mkReceived("CZK", 605);
|
||||
await mkReceived("EUR", 363);
|
||||
|
||||
const { totals } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a search filter change the result", async () => {
|
||||
await mkReceived("CZK", 1000);
|
||||
// A row in the NEXT month must not count toward the target month's total.
|
||||
const other = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
supplier_name: "Totals Test Supplier",
|
||||
amount: 9999,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
createdReceivedIds.push(other.id);
|
||||
|
||||
const { totals } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(1000);
|
||||
|
||||
// Search on the supplier name still scopes to the target-month row.
|
||||
const { totals: searched } = await getReceivedInvoiceListTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
search: "Totals Test Supplier",
|
||||
});
|
||||
expect(amountFor(searched, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
232
src/__tests__/order-totals.test.ts
Normal file
232
src/__tests__/order-totals.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOrder, getOrderTotals } from "../services/orders.service";
|
||||
import {
|
||||
createIssuedOrder,
|
||||
getIssuedOrderTotals,
|
||||
} from "../services/issued-orders.service";
|
||||
|
||||
// Per-currency total aggregation for both order lists. These hit the real
|
||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||
// endpoints sum order NET total per currency across the WHOLE filtered set
|
||||
// (orders are not tax documents — no VAT anywhere), and that the where
|
||||
// (month/year + status) is respected.
|
||||
//
|
||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||
|
||||
const STATS_YEAR = 2097;
|
||||
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdIssuedIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdIssuedIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdOrderIds.length = 0;
|
||||
createdIssuedIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
// Finalized orders consume the shared/issued sequences — reset so we don't
|
||||
// leak a consumed number into sibling test files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "issued_order", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
function amountFor(
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined {
|
||||
return totals.find((t) => t.currency === currency)?.amount;
|
||||
}
|
||||
|
||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
||||
// (enrichOrder math — orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const res = await createOrder({
|
||||
status: "prijata",
|
||||
currency,
|
||||
language: "cs",
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
// created_at is auto-managed; pin it into the stats window so the
|
||||
// month/year filter is deterministic.
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const mk = async (status: string, monthOffset: number) => {
|
||||
const res = await createOrder({
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(
|
||||
STATS_YEAR,
|
||||
STATS_MONTH - 1 + monthOffset,
|
||||
15,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
// One 'prijata' in the target month, one 'stornovana' in the target month,
|
||||
// one 'prijata' in the NEXT month.
|
||||
await mk("prijata", 0);
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
||||
const onlyPrijata = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "prijata",
|
||||
});
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1000.
|
||||
const nextMonth = await getOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||
it("sums NET total per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. NET only
|
||||
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
||||
// CZK #1: 2 x 1000 = 2000
|
||||
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||
// EUR : 3 x 100 = 300 => EUR total 300
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const o = await createIssuedOrder({
|
||||
currency,
|
||||
order_date: dateStr,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||
createdIssuedIds.push(o.id);
|
||||
return o.id;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||
expect(amountFor(totals, "EUR")).toBe(300);
|
||||
});
|
||||
|
||||
it("respects the where: a different month and a status filter change the result", async () => {
|
||||
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
||||
const draft = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
order_date: inMonth,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
createdIssuedIds.push(draft.id);
|
||||
|
||||
const sent = await createIssuedOrder({
|
||||
status: "sent",
|
||||
currency: "CZK",
|
||||
order_date: inMonth,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in sent)
|
||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||
createdIssuedIds.push(sent.id);
|
||||
|
||||
const next = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
order_date: nextMonth,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in next)
|
||||
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||
createdIssuedIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
||||
const whole = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
||||
const onlySent = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "sent",
|
||||
});
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one order there -> 1000.
|
||||
const nextStats = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
283
src/__tests__/orders-list.test.ts
Normal file
283
src/__tests__/orders-list.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import ordersRoutes from "../routes/admin/orders";
|
||||
import {
|
||||
createOrder,
|
||||
listOrders,
|
||||
getOrder,
|
||||
getOrderAttachment,
|
||||
} from "../services/orders.service";
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdOrderIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
function failResult(res: unknown): never {
|
||||
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||
}
|
||||
|
||||
async function makeCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "Odběratel a.s." },
|
||||
});
|
||||
createdCustomerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
const baseListParams = {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
sort: "id",
|
||||
order: "desc" as const,
|
||||
};
|
||||
|
||||
describe("listOrders search filter", () => {
|
||||
it("returns the order when search matches its order_number", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
const list = await listOrders({
|
||||
...baseListParams,
|
||||
search: res.order_number!,
|
||||
});
|
||||
expect(list.data.map((o) => o.id)).toContain(res.id);
|
||||
});
|
||||
|
||||
it("excludes the order when search matches nothing", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
const list = await listOrders({
|
||||
...baseListParams,
|
||||
search: "ZZZ-NO-SUCH-ORDER-ZZZ",
|
||||
});
|
||||
expect(list.data.map((o) => o.id)).not.toContain(res.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("order attachment payload hygiene", () => {
|
||||
// The PO attachment blob must be served ONLY by GET /:id/attachment —
|
||||
// list/detail payloads carry attachment_name as the existence signal.
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
|
||||
const TEST_ADMIN = "orders_list_test_admin";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
|
||||
let adminToken: string;
|
||||
let adminUserId: number;
|
||||
|
||||
async function buildOrdersApp() {
|
||||
const fastify = Fastify({ logger: false });
|
||||
await fastify.register(cookie);
|
||||
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
// multipart is registered inside ordersRoutes itself
|
||||
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
|
||||
return fastify;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildOrdersApp();
|
||||
|
||||
// Clean up any leftover test user from a previous failed run
|
||||
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
|
||||
|
||||
const adminRole = await prisma.roles.findFirst({
|
||||
where: { name: "admin" },
|
||||
});
|
||||
if (!adminRole)
|
||||
throw new Error("Admin role not found — seed the database first");
|
||||
|
||||
const adminUser = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_ADMIN,
|
||||
email: `${TEST_ADMIN}@test.local`,
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Orders",
|
||||
last_name: "ListTest",
|
||||
is_active: true,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
adminUserId = adminUser.id;
|
||||
adminToken = jwt.sign(
|
||||
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.users.deleteMany({ where: { id: adminUserId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** One order WITH a stored PO attachment, one WITHOUT. */
|
||||
async function makeOrderPair() {
|
||||
const customer = await makeCustomer();
|
||||
const withRes = await createOrder(
|
||||
{ ...baseOrder, customer_id: customer.id, create_project: false },
|
||||
PDF_BYTES,
|
||||
"po-test.pdf",
|
||||
);
|
||||
if (!("id" in withRes)) failResult(withRes);
|
||||
createdOrderIds.push(withRes.id!);
|
||||
|
||||
const withoutRes = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in withoutRes)) failResult(withoutRes);
|
||||
createdOrderIds.push(withoutRes.id!);
|
||||
|
||||
return { withId: withRes.id!, withoutId: withoutRes.id! };
|
||||
}
|
||||
|
||||
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const list = await listOrders(baseListParams);
|
||||
const withRow = list.data.find((o) => o.id === withId);
|
||||
const withoutRow = list.data.find((o) => o.id === withoutId);
|
||||
expect(withRow).toBeTruthy();
|
||||
expect(withoutRow).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withRow!).toBe(false);
|
||||
expect("attachment_data" in withoutRow!).toBe(false);
|
||||
expect(withRow!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutRow!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const withDetail = await getOrder(withId);
|
||||
const withoutDetail = await getOrder(withoutId);
|
||||
expect(withDetail).toBeTruthy();
|
||||
expect(withoutDetail).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withDetail!).toBe(false);
|
||||
expect("attachment_data" in withoutDetail!).toBe(false);
|
||||
expect(withDetail!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutDetail!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
const headers = { authorization: `Bearer ${adminToken}` };
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
|
||||
headers,
|
||||
});
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const listJson = JSON.parse(listRes.body);
|
||||
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
|
||||
expect(listRes.body).not.toContain("attachment_data");
|
||||
|
||||
const detailRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}`,
|
||||
headers,
|
||||
});
|
||||
expect(detailRes.statusCode).toBe(200);
|
||||
expect(detailRes.body).not.toContain("attachment_data");
|
||||
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
|
||||
|
||||
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
|
||||
const attRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(attRes.statusCode).toBe(200);
|
||||
expect(attRes.headers["content-type"]).toContain("application/pdf");
|
||||
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
const noAttRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withoutId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(noAttRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const att = await getOrderAttachment(withId);
|
||||
expect(att).toBeTruthy();
|
||||
expect(att!.filename).toBe("po-test.pdf");
|
||||
expect(att!.data.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
expect(await getOrderAttachment(withoutId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listOrders month filter", () => {
|
||||
it("returns only orders whose created_at is in the given month", async () => {
|
||||
const customer = await makeCustomer();
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in res)) failResult(res);
|
||||
createdOrderIds.push(res.id!);
|
||||
|
||||
// Pin created_at to a known month so the range filter is deterministic.
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: { created_at: new Date(2026, 2, 15, 12, 0, 0) },
|
||||
});
|
||||
|
||||
const inMonth = await listOrders({
|
||||
...baseListParams,
|
||||
month: 3,
|
||||
year: 2026,
|
||||
});
|
||||
expect(inMonth.data.map((o) => o.id)).toContain(res.id);
|
||||
|
||||
const otherMonth = await listOrders({
|
||||
...baseListParams,
|
||||
month: 4,
|
||||
year: 2026,
|
||||
});
|
||||
expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id);
|
||||
});
|
||||
});
|
||||
247
src/__tests__/pdf-no-vat.test.ts
Normal file
247
src/__tests__/pdf-no-vat.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
|
||||
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||||
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||||
// is covered in issued-orders.test.ts.)
|
||||
|
||||
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||||
// total label alone carries the information. Pin its absence too.
|
||||
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||||
const EN_NOTE = "Prices are exclusive of VAT";
|
||||
|
||||
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||
const order = {
|
||||
order_number: "26730042",
|
||||
customer_order_number: "PO-XYZ",
|
||||
created_at: new Date("2026-06-09T12:00:00"),
|
||||
currency: "CZK",
|
||||
notes: null,
|
||||
customers: null,
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
description: "Materiál",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 100,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
];
|
||||
|
||||
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain(">DPH<");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||||
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
});
|
||||
|
||||
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||||
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||||
expect(html).not.toContain("VAT%");
|
||||
expect(html).toContain("Total excl. VAT");
|
||||
expect(html).not.toContain(EN_NOTE);
|
||||
});
|
||||
|
||||
it("excludes not-included lines from the NET total", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
...items,
|
||||
{
|
||||
description: "Mimo cenu",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 999,
|
||||
is_included_in_total: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||
expect(html).toContain("200,00 CZK");
|
||||
});
|
||||
|
||||
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
{
|
||||
description: "Velká položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||||
expect(html).toContain("2 000,00 CZK");
|
||||
expect(html).not.toContain("1 199,00 CZK");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offer PDF route (returns the HTML for the print view) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
const createdQuotationIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||||
// Direct prisma insert (explicit number → no sequence consumed).
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("Celkem bez DPH");
|
||||
expect(html).not.toContain(CS_NOTE);
|
||||
expect(html).not.toContain("%DPH");
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
expect(html).not.toContain("Celkem k úhradě");
|
||||
});
|
||||
|
||||
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Půldenní sazba",
|
||||
quantity: 1.5,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||||
// decimals elsewhere (covered by the previous test's qty 2).
|
||||
expect(html).toContain("1,50 / ks");
|
||||
expect(html).not.toContain(">2 / ks<");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||||
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||||
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||||
const invoice = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
items: [
|
||||
{
|
||||
description: "PDF-SHARED-MARKER",
|
||||
quantity: 2,
|
||||
unit_price: 1000,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
createdInvoiceIds.push(invoice.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
|
||||
expect(html).toContain("PDF-SHARED-MARKER");
|
||||
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||||
expect(html).toContain("2 000,00");
|
||||
expect(html).toContain("Celkem k úhradě");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import planRoutes from "../routes/admin/plan";
|
||||
import { localDateStr } from "../utils/date";
|
||||
import {
|
||||
resolveCell,
|
||||
resolveGrid,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
deleteOverride,
|
||||
listEntries,
|
||||
listOverrides,
|
||||
bulkCreateEntries,
|
||||
} from "../services/plan.service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,6 +32,14 @@ import {
|
||||
const N = "wh_plan_";
|
||||
|
||||
let adminUserId: number;
|
||||
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
|
||||
// tests (and as the second employee in the multi-user bulk test). Creating our
|
||||
// own user — instead of grabbing "the second user in the DB" — guarantees it is
|
||||
// never accidentally the admin (which would make `toEqual([])` pass for the
|
||||
// wrong reason on a single-user DB), and that it is genuinely distinct so the
|
||||
// 2-employee aggregate assertion can't collapse to 1.
|
||||
let scopeUserId: number;
|
||||
let scopeRoleId: number;
|
||||
let noPermUserId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -42,11 +52,44 @@ beforeAll(async () => {
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
|
||||
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
||||
// for create tests. Just pick the second user.
|
||||
const allUsers = await prisma.users.findMany({ take: 2 });
|
||||
noPermUserId =
|
||||
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
||||
// Dedicated non-admin user + role for scoping/multi-user tests.
|
||||
const stamp = Date.now();
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: `scope_plan_${stamp}`,
|
||||
display_name: "Plan Scope Test",
|
||||
},
|
||||
});
|
||||
scopeRoleId = role.id;
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `scope_plan_${stamp}`,
|
||||
first_name: "Scope",
|
||||
last_name: "User",
|
||||
email: `scope_plan_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
scopeUserId = user.id;
|
||||
if (scopeUserId === adminUserId)
|
||||
throw new Error("scope user must be distinct from admin");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
|
||||
// leftover FK (none expected — its rows are note-prefixed and removed) can't
|
||||
// mask real failures.
|
||||
if (scopeUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: scopeUserId } })
|
||||
.catch(() => {});
|
||||
if (scopeRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: scopeRoleId } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -58,6 +101,20 @@ beforeEach(async () => {
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
// Also clean up empty-note rows on the specific test dates used by the
|
||||
// "allows an empty note" tests. These rows are not caught by the prefix
|
||||
// filter above, and would cause the per-cell cap check to reject them
|
||||
// once three accumulate across runs.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: {
|
||||
date_from: {
|
||||
in: [
|
||||
new Date("2099-07-15T00:00:00.000Z"),
|
||||
new Date("2097-08-05T00:00:00.000Z"),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -75,10 +132,9 @@ afterAll(async () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveCell", () => {
|
||||
it("returns null when nothing covers the date", async () => {
|
||||
// No entry, no override for (adminUserId, "2099-01-01")
|
||||
it("returns [] when nothing covers the date", async () => {
|
||||
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the entry that covers the date", async () => {
|
||||
@@ -93,17 +149,14 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("entry");
|
||||
expect(result?.note).toBe(`${N}PLC upgrade`);
|
||||
expect(result?.category).toBe("work");
|
||||
expect(result?.rangeFrom).toBe("2099-06-01");
|
||||
expect(result?.rangeTo).toBe("2099-06-10");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].source).toBe("entry");
|
||||
expect(result[0].note).toBe(`${N}PLC upgrade`);
|
||||
expect(result[0].rangeFrom).toBe("2099-06-01");
|
||||
expect(result[0].rangeTo).toBe("2099-06-10");
|
||||
});
|
||||
|
||||
it("returns the override for that day, not the entry", async () => {
|
||||
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
||||
// takes precedence.
|
||||
it("returns overrides (entries hidden) when an override exists", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
@@ -124,10 +177,51 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("override");
|
||||
expect(result?.note).toBe(`${N}Volno po noční`);
|
||||
expect(result?.category).toBe("leave");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].source).toBe("override");
|
||||
expect(result[0].note).toBe(`${N}Volno po noční`);
|
||||
});
|
||||
|
||||
it("returns multiple additive entries newest-first", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}first`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-05"),
|
||||
date_to: new Date("2099-06-05"),
|
||||
category: "work",
|
||||
note: `${N}second`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].note).toBe(`${N}second`); // newest first
|
||||
});
|
||||
|
||||
it("caps the returned records at MAX_RECORDS_PER_CELL", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-06"),
|
||||
category: "leave",
|
||||
note: `${N}cap${i}`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const result = await resolveCell(adminUserId, "2099-06-06");
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores soft-deleted entries and overrides", async () => {
|
||||
@@ -143,7 +237,7 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,11 +258,11 @@ describe("plan.service.resolveGrid", () => {
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
|
||||
expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies override on a covered day", async () => {
|
||||
@@ -192,9 +286,78 @@ describe("plan.service.resolveGrid", () => {
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
||||
});
|
||||
|
||||
it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => {
|
||||
// Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry
|
||||
// covering the same day and a 4th overlapping one push past the cap, so the
|
||||
// day must still return exactly 3 (mirrors resolveCell's cap behaviour).
|
||||
//
|
||||
// created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range
|
||||
// tops out at 2038), so rows created in the same second tie on the
|
||||
// `created_at desc` sort. We set explicit, distinct in-range created_at
|
||||
// values so the newest-first assertion is deterministic.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-11"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g1`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g2`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1
|
||||
},
|
||||
});
|
||||
// Two-entry day: assert length 2 and newest-first ordering.
|
||||
const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12");
|
||||
expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2);
|
||||
expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first
|
||||
// Day with no second entry stays single.
|
||||
expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1);
|
||||
|
||||
// Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g3`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T10:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g4`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T11:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const cappedDay = await resolveGrid(
|
||||
[adminUserId],
|
||||
"2099-06-12",
|
||||
"2099-06-12",
|
||||
);
|
||||
expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
|
||||
const result = assertNotPastDate("2000-01-01", true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
|
||||
// Deterministic without mocking the clock: compute "today" exactly the way
|
||||
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
|
||||
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
|
||||
// would wrongly reject editing today's plan.
|
||||
const today = localDateStr(new Date());
|
||||
expect(assertNotPastDate(today, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects YESTERDAY (local Prague date) as a past date", () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1); // local-time yesterday
|
||||
const yesterday = localDateStr(d);
|
||||
const result = assertNotPastDate(yesterday, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,6 +522,36 @@ describe("plan.service.createEntry", () => {
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects a 4th entry covering a day already at the cap", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-20",
|
||||
date_to: "2099-07-20",
|
||||
category: "work",
|
||||
note: `${N}e${i}`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in r).toBe(true);
|
||||
}
|
||||
const fourth = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-20",
|
||||
date_to: "2099-07-20",
|
||||
category: "work",
|
||||
note: `${N}e3`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in fourth).toBe(true);
|
||||
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -370,7 +581,7 @@ describe("plan.service.updateEntry", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -439,7 +650,7 @@ describe("plan.service.deleteEntry", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createOverride", () => {
|
||||
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
|
||||
it("creates an override and returns { data, oldData: null }", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
@@ -454,42 +665,45 @@ describe("plan.service.createOverride", () => {
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}day off`);
|
||||
expect(result.oldData).toBeNull();
|
||||
expect(result.replacedData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
||||
await createOverride(
|
||||
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}o${i}`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in r).toBe(true);
|
||||
}
|
||||
// A 4th record on the same day is rejected by the cap.
|
||||
const fourth = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}first`,
|
||||
note: `${N}o3`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const second = await createOverride(
|
||||
{
|
||||
expect("error" in fourth).toBe(true);
|
||||
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||
// All three earlier overrides remain active — no replace happened.
|
||||
const active = await prisma.work_plan_overrides.findMany({
|
||||
where: {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "sick",
|
||||
note: `${N}second`,
|
||||
shift_date: new Date("2099-10-02"),
|
||||
is_deleted: false,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in second).toBe(true);
|
||||
if ("data" in second) {
|
||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
||||
}
|
||||
|
||||
const all = await prisma.work_plan_overrides.findMany({
|
||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
||||
});
|
||||
// The first should be soft-deleted, the second active
|
||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
||||
expect(active.length).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
@@ -532,7 +746,7 @@ describe("plan.service.updateOverride", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -607,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false, // not admin
|
||||
);
|
||||
// A non-admin actor querying ANOTHER user's data is scoped to its own
|
||||
// user_id, so it sees none of the admin's entries. This can only be
|
||||
// [] for the right reason because scopeUserId !== adminUserId (asserted
|
||||
// in beforeAll).
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -628,13 +846,193 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listOverrides(
|
||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false,
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulkCreateEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.bulkCreateEntries", () => {
|
||||
it("creates one continuous range per employee when weekends are included", async () => {
|
||||
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||
// range 06-01..06-03 covering all 3 days.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-wknd`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(1);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
expect(res.data.users).toBe(1);
|
||||
});
|
||||
|
||||
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-05",
|
||||
include_weekends: false,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-week`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
});
|
||||
|
||||
it("skips days already at the cap and splits the range around them", async () => {
|
||||
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2096-07-02"),
|
||||
date_to: new Date("2096-07-02"),
|
||||
category: "work",
|
||||
note: `${N}cap${i}`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-07-01",
|
||||
date_to: "2096-07-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-cap`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
expect(res.data.skipped_days).toBe(1);
|
||||
});
|
||||
|
||||
it("aggregates across multiple employees", async () => {
|
||||
// Two genuinely distinct employees (admin + the dedicated scope user) so
|
||||
// `users` can't collapse to 1 if the DB happened to have a single user.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId, scopeUserId],
|
||||
date_from: "2096-08-03", // Friday
|
||||
date_to: "2096-08-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-multi`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.users).toBe(2);
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||
const empty = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in empty && empty.status).toBe(400);
|
||||
|
||||
const tooLong = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-01-01",
|
||||
date_to: "2096-12-31", // > 92 days
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in tooLong && tooLong.status).toBe(400);
|
||||
|
||||
const past = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in past && past.status).toBe(403);
|
||||
|
||||
const badRange = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-10",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badRange && badRange.status).toBe(400);
|
||||
|
||||
const badCat = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "__nope__",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badCat && badCat.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP route tests (Fastify inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -810,7 +1208,7 @@ describe("HTTP /api/admin/plan", () => {
|
||||
const body = res.json();
|
||||
expect(body.data.users).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe(
|
||||
`${N}grid test`,
|
||||
);
|
||||
});
|
||||
@@ -883,4 +1281,32 @@ describe("HTTP /api/admin/plan", () => {
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-01",
|
||||
date_to: "2095-06-01",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-forbidden`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||
date_to: "2095-06-05",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-http`,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.created_days).toBe(1);
|
||||
expect(body.data.users).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
listPlanCategories,
|
||||
@@ -12,10 +12,28 @@ import {
|
||||
|
||||
const created: number[] = [];
|
||||
|
||||
// Every test category's slug starts with "test_" AND contains "_zz" (labels all
|
||||
// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids —
|
||||
// means a previous crashed run can't leave a `test_kategorie_zz` row behind that
|
||||
// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow
|
||||
// pattern avoids touching real seed categories (work/other/…).
|
||||
async function cleanupByPrefix() {
|
||||
await prisma.plan_categories.deleteMany({
|
||||
where: { key: { startsWith: "test_", contains: "_zz" } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanupByPrefix();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
||||
}
|
||||
// Belt-and-suspenders: also remove anything matching the slug prefix in case
|
||||
// a test threw before pushing its id into `created`.
|
||||
await cleanupByPrefix();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -24,6 +42,13 @@ describe("slugifyCategory", () => {
|
||||
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
||||
expect(slugifyCategory("Příprava")).toBe("priprava");
|
||||
});
|
||||
|
||||
it("collapses an all-symbol label to an empty slug", () => {
|
||||
// No alphanumerics survive → empty string. createPlanCategory's uniqueKey
|
||||
// then falls back to "kategorie" so the row is still creatable.
|
||||
expect(slugifyCategory("***")).toBe("");
|
||||
expect(slugifyCategory(" / ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan category service", () => {
|
||||
|
||||
51
src/__tests__/received-invoices-vat.test.ts
Normal file
51
src/__tests__/received-invoices-vat.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { vatFromGross } from "../routes/admin/received-invoices";
|
||||
|
||||
// `amount` on a received invoice is the GROSS total (VAT included). The VAT is
|
||||
// the portion contained within it: gross * rate / (100 + rate).
|
||||
describe("vatFromGross (VAT contained in a gross total)", () => {
|
||||
it("derives VAT from a real gross total at 21%", () => {
|
||||
// 22 542,91 @ 21% → 3 912,41 (base 18 630,50). User-confirmed figures.
|
||||
expect(vatFromGross(22542.91, 21)).toBeCloseTo(3912.41, 2);
|
||||
expect(22542.91 - vatFromGross(22542.91, 21)).toBeCloseTo(18630.5, 2);
|
||||
});
|
||||
|
||||
it("derives VAT at other rates", () => {
|
||||
expect(vatFromGross(1210, 21)).toBeCloseTo(210, 2); // base 1000
|
||||
expect(vatFromGross(1100, 10)).toBeCloseTo(100, 2); // base 1000
|
||||
expect(vatFromGross(1150, 15)).toBeCloseTo(150, 2); // base 1000
|
||||
});
|
||||
|
||||
it("returns 0 when there is no VAT", () => {
|
||||
expect(vatFromGross(5000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for a zero amount", () => {
|
||||
expect(vatFromGross(0, 21)).toBe(0);
|
||||
});
|
||||
|
||||
it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => {
|
||||
// 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent
|
||||
// up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of-
|
||||
// round regression (which would yield 20.04) fails.
|
||||
expect(vatFromGross(115.5, 21)).toBe(20.05);
|
||||
// 95.70 @ 21% -> raw 16.609090... rounds up to 16.61.
|
||||
expect(vatFromGross(95.7, 21)).toBe(16.61);
|
||||
});
|
||||
|
||||
it("rounds the cent DOWN when below the half-cent", () => {
|
||||
// 100.40 @ 21% -> raw 17.424793... rounds down to 17.42.
|
||||
expect(vatFromGross(100.4, 21)).toBe(17.42);
|
||||
});
|
||||
|
||||
it("pins the result to the exact stored 2-dp value (not just close)", () => {
|
||||
// The DB column is Decimal(_, 2). vatFromGross must already return a value
|
||||
// with no third-decimal float drift. Strict equality + a 2-dp self-check,
|
||||
// NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail.
|
||||
const v = vatFromGross(1210, 21);
|
||||
expect(v).toBe(210); // exact, not ~210
|
||||
expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean
|
||||
// The real-world figure too: pinned exactly, replacing the toBeCloseTo above.
|
||||
expect(vatFromGross(22542.91, 21)).toBe(3912.41);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
|
||||
import {
|
||||
ReceiptItemSchema,
|
||||
CreateSupplierSchema,
|
||||
} from "../schemas/warehouse.schema";
|
||||
import { UpdateCompanySettingsSchema } from "../schemas/settings.schema";
|
||||
|
||||
describe("Zod NaN rejection", () => {
|
||||
// These schemas now build on the shared form-coercion helpers in
|
||||
// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …).
|
||||
// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers
|
||||
// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN
|
||||
// string. The original suite only asserted (b); these tests pin (a) as well.
|
||||
|
||||
describe("Zod form coercion + NaN rejection", () => {
|
||||
describe("CreateOrderSchema", () => {
|
||||
it("rejects NaN string in quantity", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
@@ -27,25 +40,24 @@ describe("Zod NaN rejection", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING quantity/unit_price to a number", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "2", unit_price: "100.5" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const item = result.data.items![0];
|
||||
expect(item.quantity).toBe(2);
|
||||
expect(typeof item.quantity).toBe("number");
|
||||
expect(item.unit_price).toBe(100.5);
|
||||
expect(typeof item.unit_price).toBe("number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
it("rejects NaN string in top-level vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "bad",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: 21,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects NaN string in item quantity", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
@@ -53,5 +65,222 @@ describe("Zod NaN rejection", () => {
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces a valid numeric STRING item quantity/unit_price", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "3", unit_price: "250" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const item = result.data.items![0];
|
||||
expect(item.quantity).toBe(3);
|
||||
expect(item.unit_price).toBe(250);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The remaining schemas were the ones flagged as accepting NaN before the
|
||||
// shared helpers landed. Assert both the coercion and the rejection paths.
|
||||
|
||||
describe("CreateTripSchema", () => {
|
||||
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: "5",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "100",
|
||||
end_km: "150.5",
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.vehicle_id).toBe(5);
|
||||
expect(result.data.start_km).toBe(100);
|
||||
expect(result.data.end_km).toBe(150.5);
|
||||
expect(typeof result.data.start_km).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in start_km", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: 5,
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "not-a-number",
|
||||
end_km: 150,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN / non-positive string in vehicle_id (FK)", () => {
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: "abc",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: 100,
|
||||
end_km: 150,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateReceivedInvoiceSchema", () => {
|
||||
it("coerces numeric STRING month / year / amount to numbers", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: "6",
|
||||
year: "2026",
|
||||
amount: "1210.50",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.month).toBe(6);
|
||||
expect(result.data.year).toBe(2026);
|
||||
expect(result.data.amount).toBe(1210.5);
|
||||
expect(typeof result.data.amount).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in amount", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: 6,
|
||||
year: 2026,
|
||||
amount: "not-money",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range month (numberInRange 1-12)", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: 13,
|
||||
year: 2026,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN string in month", () => {
|
||||
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||
supplier_name: "ACME s.r.o.",
|
||||
month: "bad",
|
||||
year: 2026,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehouse ReceiptItemSchema", () => {
|
||||
it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: "7",
|
||||
quantity: "12.5",
|
||||
unit_price: "99",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item_id).toBe(7);
|
||||
expect(result.data.quantity).toBe(12.5);
|
||||
expect(result.data.unit_price).toBe(99);
|
||||
expect(typeof result.data.quantity).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a NaN string in quantity", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: 7,
|
||||
quantity: "lots",
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-positive quantity (positiveNumberFromForm)", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: 7,
|
||||
quantity: 0,
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN / non-positive string in item_id (FK)", () => {
|
||||
const result = ReceiptItemSchema.safeParse({
|
||||
item_id: "abc",
|
||||
quantity: 5,
|
||||
unit_price: 99,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for the 2026-06-09 audit fix pass: the schema hardening
|
||||
// must NOT reject input the app legitimately sends.
|
||||
describe("schema hardening — does not reject previously-valid input", () => {
|
||||
it("settings: optional email fields accept an empty string (un-filled)", () => {
|
||||
const r = UpdateCompanySettingsSchema.safeParse({
|
||||
invoice_alert_email: "",
|
||||
leave_notify_email: "",
|
||||
smtp_from: "",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("settings: a valid email is accepted, a malformed one rejected", () => {
|
||||
expect(
|
||||
UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
|
||||
// email/phone/contact_person/notes/address columns were replaced by the
|
||||
// customers-model custom_fields blob (2026-06); legacy clients still
|
||||
// sending them must not 400 — z.object strips unknown keys.
|
||||
const parsed = CreateSupplierSchema.safeParse({
|
||||
name: "Dodavatel",
|
||||
email: "x@y.cz",
|
||||
phone: "123",
|
||||
contact_person: "Jan",
|
||||
notes: "n",
|
||||
address: "Ulice 1",
|
||||
custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect("email" in parsed.data).toBe(false);
|
||||
expect("address" in parsed.data).toBe(false);
|
||||
expect(parsed.data.custom_fields).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||
const base = {
|
||||
vehicle_id: 1,
|
||||
start_km: 100,
|
||||
end_km: 120,
|
||||
route_from: "A",
|
||||
route_to: "B",
|
||||
};
|
||||
// Plain date-only (from the date picker).
|
||||
const a = CreateTripSchema.safeParse({ ...base, trip_date: "2026-06-09" });
|
||||
expect(a.success).toBe(true);
|
||||
// The edit form re-submits what the API serialized for a @db.Date column
|
||||
// via the Date.toJSON override ("YYYY-MM-DDT00:00:00") — must still pass and
|
||||
// normalize to the date-only value.
|
||||
const b = UpdateTripSchema.safeParse({ trip_date: "2026-06-09T00:00:00" });
|
||||
expect(b.success).toBe(true);
|
||||
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||
// Genuinely malformed dates are still rejected.
|
||||
expect(
|
||||
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,44 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env.test" });
|
||||
|
||||
const ENV_TEST_PATH = ".env.test";
|
||||
|
||||
// vitest.config.ts already loads .env.test with override:true; we reload here so
|
||||
// the setup file is self-contained when run directly.
|
||||
dotenv.config({ path: ENV_TEST_PATH });
|
||||
|
||||
/**
|
||||
* Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and
|
||||
* MUTATES it, so it must never run against a dev/production DB. We require the
|
||||
* configured database name to contain "test" (the committed `.env.test` points
|
||||
* at the throwaway `app_test`); anything else is a HARD FAILURE before any test
|
||||
* runs, so a stray DATABASE_URL can't silently corrupt dev/prod data.
|
||||
*/
|
||||
function extractDbName(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
// mysql://user:pass@host:3306/dbname?params → "dbname"
|
||||
const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, "");
|
||||
const dbName = afterAuthority.split("?")[0].split("/")[0];
|
||||
return dbName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
const dbName = extractDbName(dbUrl);
|
||||
|
||||
if (!dbUrl) {
|
||||
throw new Error(
|
||||
"[test-setup] DATABASE_URL is not set. The suite mutates a real database — " +
|
||||
`point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dbName == null || !/test/i.test(dbName)) {
|
||||
throw new Error(
|
||||
`[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` +
|
||||
`whose name does not contain "test". The suite mutates the database — point ` +
|
||||
`${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`,
|
||||
);
|
||||
}
|
||||
|
||||
239
src/__tests__/totp-backup.test.ts
Normal file
239
src/__tests__/totp-backup.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as OTPAuthLib from "otpauth";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { encrypt } from "../utils/encryption";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "totp_backup_test_";
|
||||
const TEST_USERNAME = `${N}user`;
|
||||
|
||||
// Plaintext backup codes seeded for the fixture user (the enable route stores
|
||||
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
|
||||
const BACKUP_CODE_1 = "AAAA1111";
|
||||
const BACKUP_CODE_2 = "BBBB2222";
|
||||
|
||||
let testUserId: number;
|
||||
|
||||
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
|
||||
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
|
||||
const cookies = res.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
|
||||
| string
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
|
||||
async function issueLoginToken(userId: number): Promise<string> {
|
||||
const raw = crypto.randomBytes(32).toString("hex");
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
await prisma.totp_login_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hash,
|
||||
expires_at: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
// /totp/backup-verify carries its own per-IP rate limit
|
||||
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
|
||||
// more attempts than that — give each request a distinct source IP.
|
||||
let ipCounter = 0;
|
||||
function postBackupVerify(payload: Record<string, unknown>) {
|
||||
ipCounter += 1;
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/totp/backup-verify",
|
||||
payload,
|
||||
remoteAddress: `10.99.0.${ipCounter}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureUser() {
|
||||
const user = await prisma.users.findUniqueOrThrow({
|
||||
where: { id: testUserId },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run.
|
||||
const leftovers = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (leftovers.length) {
|
||||
const ids = leftovers.map((u) => u.id);
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: { in: ids } },
|
||||
});
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
}
|
||||
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
const hashedCodes = [
|
||||
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
|
||||
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
|
||||
];
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_USERNAME,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: await bcrypt.hash(
|
||||
"irrelevant",
|
||||
config.security.bcryptCost,
|
||||
),
|
||||
first_name: "TotpBackup",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: true,
|
||||
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
|
||||
totp_backup_codes: JSON.stringify(hashedCodes),
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refresh_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.totp_login_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/totp/backup-verify", () => {
|
||||
it("returns 400 for missing fields", async () => {
|
||||
const res = await postBackupVerify({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid login token", async () => {
|
||||
const res = await postBackupVerify({
|
||||
login_token: "not-a-real-token",
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong backup code without consuming the stored codes", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: "ZZZZ9999",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
// Both backup codes must still be stored — nothing consumed.
|
||||
const user = await fixtureUser();
|
||||
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
|
||||
|
||||
// Reset the failed-attempt counter so this test can't lock the fixture
|
||||
// user for later tests (max_login_attempts comes from system settings).
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.access_token.length).toBeGreaterThan(0);
|
||||
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
|
||||
// Same login completion as the TOTP path: httpOnly refresh cookie set.
|
||||
const cookie = extractCookie(res, "refresh_token");
|
||||
expect(cookie).toBeTruthy();
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(/HttpOnly/i);
|
||||
expect(raw).toMatch(/SameSite=Strict/i);
|
||||
|
||||
// The used backup code must be removed (single-use).
|
||||
const user = await fixtureUser();
|
||||
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
|
||||
expect(remaining).toHaveLength(1);
|
||||
|
||||
// The login token must be consumed: replaying it (even with the second,
|
||||
// still-valid backup code) is rejected.
|
||||
const replay = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects reuse of an already-consumed backup code", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
remember_me: true,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(
|
||||
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
|
||||
);
|
||||
|
||||
const refreshRow = await prisma.refresh_tokens.findFirst({
|
||||
where: { user_id: testUserId },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(refreshRow?.remember_me).toBe(true);
|
||||
});
|
||||
});
|
||||
541
src/__tests__/trips.test.ts
Normal file
541
src/__tests__/trips.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import tripsRoutes from "../routes/admin/trips";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route-level tests for the trips domain:
|
||||
//
|
||||
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
|
||||
// (UpdateTripSchema previously had no vehicle_id, so the edit modal's
|
||||
// vehicle change was silently dropped while the UI reported success) and
|
||||
// recomputes actual_km on BOTH vehicles.
|
||||
// 2. GET /trips/stats aggregates count/total/business/private km over the
|
||||
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
||||
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
||||
// non-managers to their own trips exactly like the list does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
||||
const N = "trips_test_";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
let scopeUserId: number;
|
||||
let scopeRoleId: number;
|
||||
let scopeToken: string;
|
||||
let vehicleAId: number;
|
||||
let vehicleBId: number;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
|
||||
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authGet(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function authPut(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
function tripRow(params: {
|
||||
userId: number;
|
||||
vehicleId: number;
|
||||
date: string;
|
||||
startKm: number;
|
||||
dist: number;
|
||||
isBusiness: boolean;
|
||||
}) {
|
||||
return {
|
||||
user_id: params.userId,
|
||||
vehicle_id: params.vehicleId,
|
||||
trip_date: new Date(params.date),
|
||||
start_km: params.startKm,
|
||||
end_km: params.startKm + params.dist,
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
is_business: params.isBusiness,
|
||||
notes: `${N}fixture`,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtureTrips() {
|
||||
await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Dedicated non-admin user with trips.record + trips.history (but NOT
|
||||
// trips.manage) for the own-trips scoping tests.
|
||||
const stamp = Date.now().toString(36);
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: `${N}role_${stamp}`,
|
||||
display_name: "Trips Scope Test",
|
||||
},
|
||||
});
|
||||
scopeRoleId = role.id;
|
||||
const perms = await prisma.permissions.findMany({
|
||||
where: { name: { in: ["trips.record", "trips.history"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (perms.length !== 2)
|
||||
throw new Error(
|
||||
"Test setup: trips.record/trips.history permissions missing",
|
||||
);
|
||||
await prisma.role_permissions.createMany({
|
||||
data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
|
||||
});
|
||||
const scopeUser = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user_${stamp}`,
|
||||
first_name: "Trips",
|
||||
last_name: "Scope",
|
||||
email: `${N}${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
scopeUserId = scopeUser.id;
|
||||
if (scopeUserId === adminUserId)
|
||||
throw new Error("scope user must be distinct from admin");
|
||||
scopeToken = generateToken({
|
||||
id: scopeUser.id,
|
||||
username: scopeUser.username,
|
||||
roleName: role.name,
|
||||
});
|
||||
|
||||
// Two dedicated vehicles (spz is unique, VarChar(20)).
|
||||
const vehicleA = await prisma.vehicles.create({
|
||||
data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
|
||||
});
|
||||
const vehicleB = await prisma.vehicles.create({
|
||||
data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
|
||||
});
|
||||
vehicleAId = vehicleA.id;
|
||||
vehicleBId = vehicleB.id;
|
||||
|
||||
await cleanupFixtureTrips();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtureTrips();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtureTrips();
|
||||
await prisma.vehicles
|
||||
.deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
|
||||
.catch(() => {});
|
||||
if (scopeUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: scopeUserId } })
|
||||
.catch(() => {});
|
||||
if (scopeRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: scopeRoleId } })
|
||||
.catch(() => {});
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/trips/:id — vehicle_id", () => {
|
||||
it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-04-10",
|
||||
startKm: 1000,
|
||||
dist: 500,
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// The edit form sends the id as a string (Select value) — intIdFromForm
|
||||
// must coerce it.
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
vehicle_id: String(vehicleBId),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
|
||||
expect(updated!.vehicle_id).toBe(vehicleBId);
|
||||
|
||||
// New vehicle picks up the trip's odometer reading…
|
||||
const vehB = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleBId },
|
||||
});
|
||||
expect(vehB!.actual_km).toBe(1500);
|
||||
// …and the old vehicle falls back to initial_km (no trips left on it).
|
||||
const vehA = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleAId },
|
||||
});
|
||||
expect(vehA!.actual_km).toBe(500);
|
||||
});
|
||||
|
||||
it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
|
||||
// Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
|
||||
// of the vehicle's trips, not just the edited one.
|
||||
const trip1 = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-09-10",
|
||||
startKm: 1000,
|
||||
dist: 500, // end_km 1500
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
const trip2 = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-09-12",
|
||||
startKm: 1800,
|
||||
dist: 200, // end_km 2000
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
|
||||
const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
|
||||
end_km: 2200,
|
||||
});
|
||||
expect(raise.statusCode).toBe(200);
|
||||
let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
||||
expect(vehA!.actual_km).toBe(2200);
|
||||
|
||||
// …while editing the non-MAX trip recomputes but keeps MAX(end_km).
|
||||
const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
|
||||
end_km: 1600,
|
||||
});
|
||||
expect(lower.statusCode).toBe(200);
|
||||
const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
|
||||
expect(updated!.end_km).toBe(1600);
|
||||
expect(updated!.vehicle_id).toBe(vehicleAId);
|
||||
vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
||||
expect(vehA!.actual_km).toBe(2200);
|
||||
});
|
||||
|
||||
it("writes an audit row with oldValues/newValues on update", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-10-10",
|
||||
startKm: 100,
|
||||
dist: 50, // end_km 150
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
end_km: 180,
|
||||
route_to: "Ostrava",
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: { action: "update", entity_type: "trip", entity_id: trip.id },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit!.user_id).toBe(adminUserId);
|
||||
|
||||
// oldValues = the full pre-update row, newValues = the changed fields.
|
||||
expect(audit!.old_values).toBeTruthy();
|
||||
expect(audit!.new_values).toBeTruthy();
|
||||
const oldValues = JSON.parse(audit!.old_values!);
|
||||
const newValues = JSON.parse(audit!.new_values!);
|
||||
expect(oldValues.end_km).toBe(150);
|
||||
expect(oldValues.route_to).toBe("Brno");
|
||||
expect(oldValues.vehicle_id).toBe(vehicleAId);
|
||||
expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
|
||||
});
|
||||
|
||||
it("rejects an invalid vehicle_id with 400", async () => {
|
||||
const trip = await prisma.trips.create({
|
||||
data: tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-04-11",
|
||||
startKm: 100,
|
||||
dist: 50,
|
||||
isBusiness: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
||||
vehicle_id: "abc",
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
|
||||
expect(unchanged!.vehicle_id).toBe(vehicleAId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/trips/stats", () => {
|
||||
it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
|
||||
// 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
|
||||
const rows = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
rows.push(
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-05-10",
|
||||
startKm: 1000 + i * 10,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-05-15",
|
||||
startKm: 2000 + i * 7,
|
||||
dist: 7,
|
||||
isBusiness: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await prisma.trips.createMany({ data: rows });
|
||||
|
||||
// The list truncates to the default 25-row page but reports the real total…
|
||||
const listRes = await authGet(
|
||||
"/api/admin/trips?month=5&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const listBody = listRes.json();
|
||||
expect(listBody.data).toHaveLength(25);
|
||||
expect(listBody.pagination.total).toBe(30);
|
||||
expect(listBody.pagination.total_pages).toBe(2);
|
||||
|
||||
// …while the stats endpoint covers all 30 rows.
|
||||
const res = await authGet(
|
||||
"/api/admin/trips/stats?month=5&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual({
|
||||
count: 30,
|
||||
total_km: 20 * 10 + 10 * 7,
|
||||
business_km: 200,
|
||||
private_km: 70,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the month filter in both wire formats", async () => {
|
||||
await prisma.trips.createMany({
|
||||
data: [
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-06-10",
|
||||
startKm: 100,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-06-12",
|
||||
startKm: 110,
|
||||
dist: 20,
|
||||
isBusiness: false,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-07-05",
|
||||
startKm: 130,
|
||||
dist: 40,
|
||||
isBusiness: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Combined "YYYY-MM" format (TripsHistory)
|
||||
const june = await authGet(
|
||||
"/api/admin/trips/stats?month=2098-06",
|
||||
adminToken,
|
||||
);
|
||||
expect(june.statusCode).toBe(200);
|
||||
expect(june.json().data).toEqual({
|
||||
count: 2,
|
||||
total_km: 30,
|
||||
business_km: 10,
|
||||
private_km: 20,
|
||||
});
|
||||
|
||||
// Split month+year format (TripsAdmin)
|
||||
const july = await authGet(
|
||||
"/api/admin/trips/stats?month=7&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(july.statusCode).toBe(200);
|
||||
expect(july.json().data).toEqual({
|
||||
count: 1,
|
||||
total_km: 40,
|
||||
business_km: 40,
|
||||
private_km: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes non-managers to their own trips (user_id param ignored)", async () => {
|
||||
await prisma.trips.createMany({
|
||||
data: [
|
||||
// 2 trips for the scope user, 3 for the admin — same month.
|
||||
tripRow({
|
||||
userId: scopeUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-08-05",
|
||||
startKm: 100,
|
||||
dist: 10,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: scopeUserId,
|
||||
vehicleId: vehicleAId,
|
||||
date: "2098-08-06",
|
||||
startKm: 110,
|
||||
dist: 15,
|
||||
isBusiness: false,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-07",
|
||||
startKm: 200,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-08",
|
||||
startKm: 300,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
tripRow({
|
||||
userId: adminUserId,
|
||||
vehicleId: vehicleBId,
|
||||
date: "2098-08-09",
|
||||
startKm: 400,
|
||||
dist: 100,
|
||||
isBusiness: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Non-manager: only own trips, even when explicitly requesting another user.
|
||||
const own = await authGet(
|
||||
"/api/admin/trips/stats?month=8&year=2098",
|
||||
scopeToken,
|
||||
);
|
||||
expect(own.statusCode).toBe(200);
|
||||
expect(own.json().data).toEqual({
|
||||
count: 2,
|
||||
total_km: 25,
|
||||
business_km: 10,
|
||||
private_km: 15,
|
||||
});
|
||||
|
||||
const spoofed = await authGet(
|
||||
`/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
|
||||
scopeToken,
|
||||
);
|
||||
expect(spoofed.statusCode).toBe(200);
|
||||
expect(spoofed.json().data.count).toBe(2);
|
||||
|
||||
// The list applies the exact same scoping.
|
||||
const list = await authGet(
|
||||
"/api/admin/trips?month=8&year=2098",
|
||||
scopeToken,
|
||||
);
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().data).toHaveLength(2);
|
||||
|
||||
// Manager (admin) sees everything in the month.
|
||||
const all = await authGet(
|
||||
"/api/admin/trips/stats?month=8&year=2098",
|
||||
adminToken,
|
||||
);
|
||||
expect(all.statusCode).toBe(200);
|
||||
expect(all.json().data.count).toBe(5);
|
||||
expect(all.json().data.total_km).toBe(325);
|
||||
|
||||
// Manager can filter to a single user.
|
||||
const filtered = await authGet(
|
||||
`/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(filtered.statusCode).toBe(200);
|
||||
expect(filtered.json().data.count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
import { nasInvoicesManager } from "../services/nas-financials-manager";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -627,19 +637,22 @@ describe("Item CRUD", () => {
|
||||
expect(body.data.name).toBe(`${N}item1_updated`);
|
||||
});
|
||||
|
||||
it("deactivates an item (soft delete)", async () => {
|
||||
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
|
||||
// The current API HARD-deletes the item — the route does
|
||||
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
|
||||
// test name reflects that real behavior so a future intended switch to
|
||||
// soft-delete would (correctly) make this test fail and prompt a review,
|
||||
// rather than the old "soft delete" name masking the mismatch.
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
// The current API hard-deletes the item (the route does
|
||||
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
|
||||
// Verify the row is gone rather than checking is_active.
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
// The row is actually gone — a subsequent GET 404s.
|
||||
const checkRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||
@@ -876,6 +889,177 @@ describe("Receipt flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5b. Receipt attachment download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Receipt attachment download", () => {
|
||||
let receiptId: number;
|
||||
let otherReceiptId: number;
|
||||
let attachmentId: number;
|
||||
let diacriticAttachmentId: number;
|
||||
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
|
||||
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
|
||||
const diacriticFileName = "příloha č. 1.pdf";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Item + two receipts (attachment belongs to the first one only)
|
||||
const itemRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/items",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `${N}item_att_download`, unit: "ks" },
|
||||
});
|
||||
const itemId = itemRes.json().data.id;
|
||||
|
||||
const receiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_a`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
receiptId = receiptRes.json().data.id;
|
||||
|
||||
const otherReceiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_b`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
otherReceiptId = otherReceiptRes.json().data.id;
|
||||
|
||||
// Attachment row created directly — the upload endpoint needs a writable
|
||||
// NAS, which is not available in the test environment. The NAS read is
|
||||
// stubbed per-test on the singleton (same temp-dir/stub seam as the
|
||||
// nas-document-manager tests); the DB rows and route logic stay real.
|
||||
const attachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: "wh_test_priloha.pdf",
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: filePath,
|
||||
},
|
||||
});
|
||||
attachmentId = attachment.id;
|
||||
|
||||
// Diacritic filename — the norm for a Czech company. Node throws on
|
||||
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
|
||||
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: diacriticFileName,
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
|
||||
},
|
||||
});
|
||||
diacriticAttachmentId = diacriticAttachment.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downloads an existing attachment with correct content-type and bytes", async () => {
|
||||
const readSpy = vi
|
||||
.spyOn(nasInvoicesManager, "readReceived")
|
||||
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("application/pdf");
|
||||
expect(res.headers["content-disposition"]).toBe(
|
||||
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
expect(readSpy).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
|
||||
data: fileBytes,
|
||||
fileName: diacriticFileName,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const disposition = res.headers["content-disposition"];
|
||||
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
|
||||
expect(disposition).toContain(
|
||||
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
|
||||
);
|
||||
// … alongside an ASCII-only fallback, so the header never carries
|
||||
// chars > U+00FF (which Node's setHeader rejects → 500).
|
||||
expect(disposition).toBe(
|
||||
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when the attachment belongs to a different receipt", async () => {
|
||||
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
// Ownership is rejected before any NAS access
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for a nonexistent attachment id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when the file is missing on the NAS", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects download without warehouse.view permission (403)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(noPermToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Issue flow
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1030,6 +1214,124 @@ describe("Issue flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6b. FIFO ordering — the OLDEST batch is consumed first
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Issue FIFO ordering (oldest batch first)", () => {
|
||||
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
|
||||
// Build two batches for one item via two confirmed receipts.
|
||||
const itemRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/items",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `${N}item_fifo`, unit: "ks" },
|
||||
});
|
||||
const fifoItemId = itemRes.json().data.id as number;
|
||||
|
||||
const locA = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/locations",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { code: uniqueCode(), name: "fifo loc A" },
|
||||
})
|
||||
).json().data.id as number;
|
||||
const locB = (
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/locations",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { code: uniqueCode(), name: "fifo loc B" },
|
||||
})
|
||||
).json().data.id as number;
|
||||
|
||||
// Helper: create + confirm a receipt of `qty` at `locId`.
|
||||
const confirmReceipt = async (qty: number, locId: number) => {
|
||||
const r = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}fifo_receipt`,
|
||||
items: [
|
||||
{
|
||||
item_id: fifoItemId,
|
||||
quantity: qty,
|
||||
unit_price: 10,
|
||||
location_id: locId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const id = r.json().data.id as number;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/warehouse/receipts/${id}/confirm`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
};
|
||||
|
||||
// batchEarly is created FIRST (lower id); batchLate is created SECOND
|
||||
// (higher id). We then flip their received_at so the HIGHER-id batch is the
|
||||
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
|
||||
// by insertion id.
|
||||
await confirmReceipt(10, locA); // → batch with the lower id
|
||||
await confirmReceipt(10, locB); // → batch with the higher id
|
||||
|
||||
const batchesRaw = await prisma.sklad_batches.findMany({
|
||||
where: { item_id: fifoItemId },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(batchesRaw.length).toBe(2);
|
||||
const lowerId = batchesRaw[0]; // created first
|
||||
const higherId = batchesRaw[1]; // created second
|
||||
|
||||
// Make the HIGHER-id batch the OLDEST by received_at.
|
||||
await prisma.sklad_batches.update({
|
||||
where: { id: lowerId.id },
|
||||
data: { received_at: new Date("2021-01-01T00:00:00Z") },
|
||||
});
|
||||
await prisma.sklad_batches.update({
|
||||
where: { id: higherId.id },
|
||||
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
|
||||
});
|
||||
|
||||
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
|
||||
// (the higher-id one we back-dated), partially depleting it.
|
||||
const issueRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/issues",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
project_id: projectId,
|
||||
notes: `${N}fifo_issue`,
|
||||
items: [{ item_id: fifoItemId, quantity: 5 }],
|
||||
},
|
||||
});
|
||||
expect(issueRes.statusCode).toBe(201);
|
||||
const issueId = issueRes.json().data.id as number;
|
||||
const confirmRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(confirmRes.statusCode).toBe(200);
|
||||
|
||||
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
|
||||
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
|
||||
// wrongly sorted by id it would have consumed the lower-id batch instead.
|
||||
const oldest = await prisma.sklad_batches.findUnique({
|
||||
where: { id: higherId.id },
|
||||
});
|
||||
const newest = await prisma.sklad_batches.findUnique({
|
||||
where: { id: lowerId.id },
|
||||
});
|
||||
expect(Number(oldest!.quantity)).toBe(5);
|
||||
expect(Number(newest!.quantity)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Reservation flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,30 +5,14 @@ import { AuthProvider } from "./context/AuthContext";
|
||||
import { AlertProvider } from "./context/AlertContext";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import AppShell from "./ui/AppShell";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
import { LoadingState } from "./ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import "./variables.css";
|
||||
import "./base.css";
|
||||
import "./forms.css";
|
||||
import "./buttons.css";
|
||||
import "./layout.css";
|
||||
import "./components.css";
|
||||
import "./tables.css";
|
||||
import "./datepicker.css";
|
||||
import "./filemanager.css";
|
||||
import "./pagination.css";
|
||||
import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./plan.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
import "./warehouse.css";
|
||||
|
||||
const Odin = lazy(() => import("./pages/Odin"));
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||
@@ -49,6 +33,7 @@ const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
@@ -81,157 +66,178 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
{/*
|
||||
Auth gating is delegated to <AppShell>: every authenticated
|
||||
page is nested under this layout route, and AppShell
|
||||
redirects to /login when there is no user (per-page
|
||||
permission checks live in the pages themselves). NOTE: any
|
||||
NEW top-level route added OUTSIDE this <AppShell> wrapper
|
||||
(like `login`/`ui-kit` above) is PUBLIC by default — wrap it
|
||||
in AppShell or add its own guard if it needs auth.
|
||||
*/}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="odin" element={<Odin />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route
|
||||
path="orders/issued/new"
|
||||
element={<IssuedOrderDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="orders/issued/:id"
|
||||
element={<IssuedOrderDetail />}
|
||||
/>
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route
|
||||
path="warehouse/items"
|
||||
element={<WarehouseItems />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
</MuiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/admin/GlobalStyles.tsx
Normal file
162
src/admin/GlobalStyles.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||
import { fonts } from "./theme";
|
||||
|
||||
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* App-wide global styles, theme-aware via MUI's CSS variables.
|
||||
*
|
||||
* This replaces the former hand-written `base.css` (reset, typography,
|
||||
* scrollbar, ::selection, the theme cross-fade timing, utility classes).
|
||||
* It is rendered once inside MuiProvider so every rule resolves against the
|
||||
* active color scheme (`theme.vars.*`). The pre-React bootstrap spinner lives
|
||||
* inline in App.tsx instead, because it renders before MUI mounts.
|
||||
*/
|
||||
export default function AppGlobalStyles() {
|
||||
return (
|
||||
<GlobalStyles
|
||||
styles={(theme) => ({
|
||||
// ---- Reset ----
|
||||
"*": { margin: 0, padding: 0, boxSizing: "border-box" },
|
||||
html: { scrollBehavior: "smooth", overflowX: "hidden" },
|
||||
"html, body, #root": { minHeight: "100dvh", maxWidth: "100vw" },
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: "16px",
|
||||
lineHeight: 1.6,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
background: theme.vars!.palette.background.default,
|
||||
overflowX: "hidden",
|
||||
overscrollBehaviorX: "none",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
MozOsxFontSmoothing: "grayscale",
|
||||
transition: "background-color 0.3s ease, color 0.3s ease",
|
||||
},
|
||||
"#root": { overflowX: "hidden", touchAction: "pan-y pinch-zoom" },
|
||||
|
||||
// ---- Theme cross-fade (View Transitions API, see ThemeContext) ----
|
||||
"::view-transition-old(root), ::view-transition-new(root)": {
|
||||
animationDuration: "0.3s",
|
||||
animationTimingFunction: EASE,
|
||||
},
|
||||
|
||||
// ---- Typography ----
|
||||
"h1, h2, h3, h4, h5, h6": {
|
||||
fontFamily: fonts.heading,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
},
|
||||
h1: { fontSize: "clamp(2.5rem, 5vw, 4rem)" },
|
||||
h2: { fontSize: "clamp(2rem, 4vw, 3rem)" },
|
||||
h3: { fontSize: "clamp(1.25rem, 2vw, 1.5rem)" },
|
||||
p: { color: theme.vars!.palette.text.secondary, lineHeight: 1.6 },
|
||||
a: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
transition: `all 0.3s ${EASE}`,
|
||||
},
|
||||
img: { maxWidth: "100%", height: "auto" },
|
||||
|
||||
// ---- Scrollbar & selection ----
|
||||
"::-webkit-scrollbar": { width: "8px" },
|
||||
"::-webkit-scrollbar-track": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
background: theme.vars!.palette.divider,
|
||||
borderRadius: "4px",
|
||||
},
|
||||
"::-webkit-scrollbar-thumb:hover": {
|
||||
background: theme.vars!.palette.text.secondary,
|
||||
},
|
||||
"::selection": {
|
||||
background: theme.vars!.palette.primary.main,
|
||||
color: "#fff",
|
||||
},
|
||||
|
||||
// ---- Utility classes (legacy, still used across pages) ----
|
||||
".text-warning": {
|
||||
color: `${theme.vars!.palette.warning.main} !important`,
|
||||
},
|
||||
".text-danger": {
|
||||
color: `${theme.vars!.palette.error.main} !important`,
|
||||
},
|
||||
".text-success": {
|
||||
color: `${theme.vars!.palette.success.main} !important`,
|
||||
},
|
||||
".text-muted": {
|
||||
color: `${theme.vars!.palette.text.secondary} !important`,
|
||||
},
|
||||
".text-secondary": {
|
||||
color: `${theme.vars!.palette.text.secondary} !important`,
|
||||
},
|
||||
".text-tertiary": {
|
||||
color: `${theme.vars!.palette.text.disabled} !important`,
|
||||
},
|
||||
".text-accent": {
|
||||
color: `${theme.vars!.palette.primary.main} !important`,
|
||||
},
|
||||
".fw-500": { fontWeight: 500 },
|
||||
".fw-600": { fontWeight: "600 !important" },
|
||||
".link-accent": {
|
||||
color: theme.vars!.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
},
|
||||
".link-accent:hover": { textDecoration: "underline" },
|
||||
".flex-1": { flex: 1 },
|
||||
".flex-row": { display: "flex", alignItems: "center" },
|
||||
".flex-row-gap": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
".flex-between": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
".inline-flex": { display: "inline-flex", alignItems: "center" },
|
||||
".mb-1": { marginBottom: "0.25rem" },
|
||||
".mb-2": { marginBottom: "0.5rem" },
|
||||
".mb-3": { marginBottom: "0.75rem" },
|
||||
".mb-4": { marginBottom: "1rem" },
|
||||
".mb-6": { marginBottom: "1.5rem" },
|
||||
".mt-1": { marginTop: "0.25rem" },
|
||||
".mt-2": { marginTop: "0.5rem" },
|
||||
".mt-3": { marginTop: "0.75rem" },
|
||||
".mt-6": { marginTop: "1.5rem" },
|
||||
".gap-1": { gap: "0.25rem" },
|
||||
".gap-2": { gap: "0.5rem" },
|
||||
".gap-3": { gap: "0.75rem" },
|
||||
".gap-4": { gap: "1rem" },
|
||||
".gap-5": { gap: "1.25rem" },
|
||||
".gap-6": { gap: "1.5rem" },
|
||||
".text-right": { textAlign: "right" },
|
||||
".text-center": { textAlign: "center" },
|
||||
".text-xs": { fontSize: "0.75rem" },
|
||||
".text-sm": { fontSize: "0.8125rem" },
|
||||
".text-md": { fontSize: "0.875rem" },
|
||||
".text-base": { fontSize: "1rem" },
|
||||
".w-full": { width: "100%" },
|
||||
".max-w-xs": { maxWidth: "120px" },
|
||||
".max-w-sm": { maxWidth: "200px" },
|
||||
".whitespace-nowrap": { whiteSpace: "nowrap" },
|
||||
|
||||
// ---- Reduced motion ----
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"::view-transition-old(root), ::view-transition-new(root)": {
|
||||
animation: "none",
|
||||
},
|
||||
"*, *::before, *::after": {
|
||||
animationDuration: "0.01ms !important",
|
||||
animationIterationCount: "1 !important",
|
||||
transitionDuration: "0.01ms !important",
|
||||
scrollBehavior: "auto !important",
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/* ============================================================================
|
||||
Attendance Module
|
||||
============================================================================ */
|
||||
|
||||
/* Layout */
|
||||
.attendance-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.attendance-layout {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.attendance-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attendance-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.attendance-sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clock Card */
|
||||
.attendance-clock-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.attendance-clock-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.attendance-clock-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attendance-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.attendance-status-dot.active {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 50%, transparent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.attendance-clock-time {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Shift Info */
|
||||
.attendance-shift-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.attendance-shift-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.attendance-shift-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.attendance-shift-item {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.attendance-shift-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.attendance-shift-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attendance-shift-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Clock Actions */
|
||||
.attendance-clock-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Notes */
|
||||
.attendance-notes {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.attendance-notes-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Project Section */
|
||||
.attendance-project-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.attendance-project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.attendance-project-header .attendance-shift-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.attendance-project-section .admin-form-select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.attendance-project-logs {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.attendance-project-log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.attendance-project-log-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attendance-project-log-time {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.attendance-project-log-duration {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Balance Card */
|
||||
.attendance-balance-card {
|
||||
background: var(--gradient);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attendance-balance-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 0.75rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.attendance-balance-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.attendance-balance-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.attendance-balance-unit {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.attendance-balance-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.attendance-balance-bar {
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attendance-balance-progress {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Quick Links */
|
||||
.attendance-quick-links {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.attendance-quick-title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.attendance-quick-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin: -0.25rem -0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.attendance-quick-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attendance-quick-link span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.attendance-quick-link svg:last-child {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Leave Type Badges */
|
||||
.attendance-leave-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-vacation {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-sick {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-holiday {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-unpaid {
|
||||
background: var(--muted-light);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Working Status Badge */
|
||||
.attendance-working-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-working-badge.working {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.attendance-working-badge.finished {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* GPS Link */
|
||||
.attendance-gps-link {
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.attendance-gps-link:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Location Page */
|
||||
.attendance-location-map {
|
||||
height: 400px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
|
||||
}
|
||||
|
||||
.attendance-location-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.attendance-location-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.attendance-location-card.empty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attendance-location-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-location-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attendance-location-address {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.attendance-location-coords {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
/* ============================================================================
|
||||
Reset & Base
|
||||
============================================================================ */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
min-height: 100dvh;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-sidebar,
|
||||
.admin-header,
|
||||
.admin-card,
|
||||
.admin-modal {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
h3 {
|
||||
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Base / Utilities
|
||||
============================================================================ */
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--text-tertiary) !important;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.fw-600 {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.link-accent {
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-accent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Layout utilities */
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-row-gap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.mb-2 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.gap-5 {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* Typography utilities */
|
||||
.fw-500 {
|
||||
font-weight: 500;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Spinner variant */
|
||||
.admin-spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Monospace for data values (times, dates, numbers, IDs) */
|
||||
.admin-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
.admin-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-drag-handle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.admin-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Error stack (DEV only) */
|
||||
.admin-error-stack {
|
||||
max-width: 600px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--danger);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Keyboard shortcut badge */
|
||||
.admin-kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Loading & Animations */
|
||||
.admin-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--accent-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 256px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(30px, -30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Additional Utilities ─────────────────────────────────────────── */
|
||||
|
||||
/* Font sizes */
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.text-md {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Width utilities */
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-xs {
|
||||
max-width: 120px;
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Whitespace */
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Additional gaps */
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Additional margins */
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Display */
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Prefers reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.admin-spinner {
|
||||
animation-duration: 0.8s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 14px;
|
||||
/* 1px transparent border on ALL buttons so switching between primary
|
||||
and secondary doesn't cause a 1px layout shift (the border is always
|
||||
1px — only the colour changes). */
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Prevent buttons from growing when text changes to spinner + loading text */
|
||||
.admin-modal-footer .admin-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
.admin-modal-footer .admin-btn .admin-spinner {
|
||||
margin: -2px 0;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
|
||||
}
|
||||
|
||||
.admin-btn .admin-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.admin-btn-primary .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-secondary .admin-spinner {
|
||||
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
|
||||
border-top-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
border-color: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.admin-btn-danger .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-btn-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent:hover {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger:hover {
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
/* Touch targets - min 44px on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.admin-btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,223 +0,0 @@
|
||||
import { forwardRef } from "react";
|
||||
import DatePicker, { registerLocale } from "react-datepicker";
|
||||
import { cs } from "date-fns/locale";
|
||||
import { parse, format } from "date-fns";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
registerLocale("cs", cs);
|
||||
|
||||
// Ensure portal root exists
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.getElementById("datepicker-portal")
|
||||
) {
|
||||
const el = document.createElement("div");
|
||||
el.id = "datepicker-portal";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
const isTouchDevice = () =>
|
||||
typeof window !== "undefined" &&
|
||||
("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||
|
||||
interface CustomInputProps {
|
||||
value?: string;
|
||||
onClick?: () => void;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
(
|
||||
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
|
||||
ref,
|
||||
) => (
|
||||
<input
|
||||
className="admin-form-input"
|
||||
onClick={onClick}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
interface NativeInputProps {
|
||||
mode: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const modeToInputType: Record<string, string> = {
|
||||
month: "month",
|
||||
time: "time",
|
||||
};
|
||||
|
||||
function NativeInput({
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
}: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || "date";
|
||||
// For time inputs, min/max must be in HH:mm format, not date format
|
||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
||||
if (!val) return undefined;
|
||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
||||
// otherwise it's not a valid time min/max — return undefined
|
||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
||||
if (val.includes(":")) return val.substring(0, 5);
|
||||
return undefined;
|
||||
};
|
||||
const minProp =
|
||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
||||
const maxProp =
|
||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
lang="cs"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
min={minProp}
|
||||
max={maxProp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: "date" | "month" | "time";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDatePicker({
|
||||
mode = "date",
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: AdminDatePickerProps) {
|
||||
const useNative = isTouchDevice();
|
||||
|
||||
if (useNative) {
|
||||
return (
|
||||
<NativeInput
|
||||
mode={mode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const toDate = (val: string | null | undefined): Date | null => {
|
||||
if (!val) return null;
|
||||
try {
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "time") {
|
||||
const [h, m] = val.split(":");
|
||||
const d = new Date();
|
||||
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleChange = (date: Date | null) => {
|
||||
if (!date) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
|
||||
else if (mode === "time") onChange(format(date, "HH:mm"));
|
||||
else if (mode === "month") onChange(format(date, "yyyy-MM"));
|
||||
};
|
||||
|
||||
const parseMinMax = (val: string | undefined): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const customInput = (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const commonProps = {
|
||||
selected: toDate(value),
|
||||
onChange: handleChange,
|
||||
locale: "cs",
|
||||
customInput,
|
||||
placeholderText: placeholder,
|
||||
minDate: parseMinMax(minDate),
|
||||
maxDate: parseMinMax(maxDate),
|
||||
popperPlacement: "bottom-start" as const,
|
||||
portalId: "datepicker-portal",
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (mode === "time") {
|
||||
return (
|
||||
<DatePicker
|
||||
{...commonProps}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={5}
|
||||
timeCaption="Čas"
|
||||
dateFormat="HH:mm"
|
||||
timeFormat="HH:mm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "month") {
|
||||
return (
|
||||
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
|
||||
);
|
||||
}
|
||||
|
||||
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { setLogoutAlert } from "../utils/api";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ShortcutsHelp from "./ShortcutsHelp";
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
|
||||
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
|
||||
// would invalidate each other and kick the user out.
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setSidebarOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
useModalLock(sidebarOpen);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<div className="admin-loading" style={{ width: "100%" }}>
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
|
||||
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
|
||||
if (needs2FASetup && location.pathname !== "/") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-layout"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={
|
||||
loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<div className="admin-main">
|
||||
<header className="admin-header">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="admin-menu-btn"
|
||||
aria-label="Otevřít menu"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="admin-header-theme-btn"
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="admin-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<ShortcutsHelp />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user