Compare commits
7 Commits
v2.2.0
...
4e534471d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e534471d9 | ||
|
|
975a555af5 | ||
|
|
6a22195c7d | ||
|
|
74ce24e3fa | ||
|
|
8ee5a443ef | ||
|
|
b3e2abf9b2 | ||
|
|
1826fc7976 |
@@ -1,14 +1,23 @@
|
|||||||
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
||||||
let data = '';
|
//
|
||||||
process.stdin.on('data', chunk => data += chunk);
|
// Hook exit-code contract (Claude Code PreToolUse):
|
||||||
process.stdin.on('end', () => {
|
// 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 {
|
try {
|
||||||
const input = JSON.parse(data);
|
const input = JSON.parse(data);
|
||||||
const filePath = input.tool_input?.file_path || '';
|
const filePath = input.tool_input?.file_path || "";
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const basename = filePath.split("/").pop().split("\\").pop();
|
||||||
if (basename === '.env') {
|
if (basename === ".env") {
|
||||||
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
|
console.error("Direct .env edits are blocked. Use .env.example instead.");
|
||||||
process.exit(1);
|
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.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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.
|
||||||
155
package-lock.json
generated
155
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.1.5",
|
"version": "2.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.1.5",
|
"version": "2.4.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"@prisma/adapter-mariadb": "^7.8.0",
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
@@ -52,8 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
@@ -64,7 +62,6 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -1729,9 +1726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.11",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -3010,13 +3007,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/bcryptjs": {
|
|
||||||
"version": "2.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
|
||||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -3042,16 +3032,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/dompurify": {
|
|
||||||
"version": "3.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
|
||||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/trusted-types": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -3076,6 +3056,7 @@
|
|||||||
"version": "28.0.1",
|
"version": "28.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
|
||||||
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
|
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -3088,6 +3069,7 @@
|
|||||||
"version": "7.25.0",
|
"version": "7.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
|
||||||
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
|
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
@@ -3236,14 +3218,15 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"devOptional": true,
|
"license": "MIT",
|
||||||
"license": "MIT"
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
@@ -4154,36 +4137,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.1.0",
|
|
||||||
"supports-color": "^7.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk/node_modules/supports-color": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -4297,31 +4250,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concurrently": {
|
|
||||||
"version": "9.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
|
||||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "4.1.2",
|
|
||||||
"rxjs": "7.8.2",
|
|
||||||
"shell-quote": "1.8.3",
|
|
||||||
"supports-color": "8.1.1",
|
|
||||||
"tree-kill": "1.2.2",
|
|
||||||
"yargs": "17.7.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"conc": "dist/bin/concurrently.js",
|
|
||||||
"concurrently": "dist/bin/concurrently.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/confbox": {
|
"node_modules/confbox": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
@@ -4713,6 +4641,7 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@@ -5835,16 +5764,6 @@
|
|||||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/has-flag": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -7258,6 +7177,7 @@
|
|||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^6.0.0"
|
"entities": "^6.0.0"
|
||||||
@@ -8087,16 +8007,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rxjs": {
|
|
||||||
"version": "7.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -8244,19 +8154,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
|
||||||
"version": "1.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -8577,22 +8474,6 @@
|
|||||||
"node": ">=14.18.0"
|
"node": ">=14.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/supports-color": {
|
|
||||||
"version": "8.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
|
||||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
@@ -8817,16 +8698,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tree-kill": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"tree-kill": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ts-algebra": {
|
"node_modules/ts-algebra": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -47,7 +47,6 @@
|
|||||||
"@prisma/adapter-mariadb": "^7.8.0",
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
@@ -73,8 +72,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
@@ -85,7 +83,6 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -98,5 +95,8 @@
|
|||||||
"typescript-eslint": "^8.61.0",
|
"typescript-eslint": "^8.61.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@hono/node-server": "^1.19.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -193,7 +193,6 @@ model customers {
|
|||||||
sync_version Int? @default(0)
|
sync_version Int? @default(0)
|
||||||
invoices invoices[]
|
invoices invoices[]
|
||||||
orders orders[]
|
orders orders[]
|
||||||
issued_orders issued_orders[]
|
|
||||||
projects projects[]
|
projects projects[]
|
||||||
quotations quotations[]
|
quotations quotations[]
|
||||||
}
|
}
|
||||||
@@ -370,7 +369,7 @@ model orders {
|
|||||||
model issued_orders {
|
model issued_orders {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
||||||
customer_id Int?
|
supplier_id Int?
|
||||||
status issued_orders_status @default(draft)
|
status issued_orders_status @default(draft)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
@@ -387,9 +386,9 @@ model issued_orders {
|
|||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
issued_order_items issued_order_items[]
|
issued_order_items issued_order_items[]
|
||||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
|
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
||||||
|
|
||||||
@@index([customer_id], map: "issued_orders_customer_id")
|
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +795,8 @@ model sklad_suppliers {
|
|||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
receipts sklad_receipts[]
|
receipts sklad_receipts[]
|
||||||
|
issued_orders issued_orders[]
|
||||||
|
|
||||||
@@map("sklad_suppliers")
|
@@map("sklad_suppliers")
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({}); // defaults to draft
|
const draft = await createIssuedOrder({}); // defaults to draft
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
expect(draft.status).toBe("draft");
|
expect(draft.status).toBe("draft");
|
||||||
expect(draft.po_number).toBeNull();
|
expect(draft.po_number).toBeNull();
|
||||||
@@ -62,6 +64,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||||
const expected = (await previewIssuedOrderNumber()).number;
|
const expected = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|
||||||
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
||||||
@@ -77,6 +81,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
|
|
||||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|
||||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||||
@@ -104,6 +110,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
||||||
const a = await createIssuedOrder({});
|
const a = await createIssuedOrder({});
|
||||||
const b = 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);
|
issuedOrderIds.push(a.id, b.id);
|
||||||
expect(a.po_number).toBeNull();
|
expect(a.po_number).toBeNull();
|
||||||
expect(b.po_number).toBeNull();
|
expect(b.po_number).toBeNull();
|
||||||
@@ -112,6 +120,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
await deleteIssuedOrder(draft.id);
|
await deleteIssuedOrder(draft.id);
|
||||||
const after = (await previewIssuedOrderNumber()).number;
|
const after = (await previewIssuedOrderNumber()).number;
|
||||||
expect(after).toBe(before);
|
expect(after).toBe(before);
|
||||||
|
|||||||
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,6 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
import prisma from "../config/database";
|
||||||
|
import {
|
||||||
|
invoiceTotalWithVat,
|
||||||
|
createInvoice,
|
||||||
|
updateInvoice,
|
||||||
|
listInvoices,
|
||||||
|
getInvoiceListTotals,
|
||||||
|
} from "../services/invoices.service";
|
||||||
|
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
||||||
|
|
||||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||||
@@ -118,3 +126,138 @@ describe("invoiceTotalWithVat", () => {
|
|||||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
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({ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
import {
|
import {
|
||||||
generateIssuedOrderNumber,
|
generateIssuedOrderNumber,
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
@@ -13,7 +16,9 @@ import {
|
|||||||
listIssuedOrders,
|
listIssuedOrders,
|
||||||
updateIssuedOrder,
|
updateIssuedOrder,
|
||||||
deleteIssuedOrder,
|
deleteIssuedOrder,
|
||||||
|
type IssuedOrderInput,
|
||||||
} from "../services/issued-orders.service";
|
} from "../services/issued-orders.service";
|
||||||
|
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||||
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -53,12 +58,12 @@ describe("issued-order numbering", () => {
|
|||||||
describe("CreateIssuedOrderSchema", () => {
|
describe("CreateIssuedOrderSchema", () => {
|
||||||
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
||||||
const ok = CreateIssuedOrderSchema.safeParse({
|
const ok = CreateIssuedOrderSchema.safeParse({
|
||||||
customer_id: "5",
|
supplier_id: "5",
|
||||||
vat_rate: "21",
|
vat_rate: "21",
|
||||||
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
||||||
});
|
});
|
||||||
expect(ok.success).toBe(true);
|
expect(ok.success).toBe(true);
|
||||||
if (ok.success) expect(ok.data.customer_id).toBe(5);
|
if (ok.success) expect(ok.data.supplier_id).toBe(5);
|
||||||
|
|
||||||
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
||||||
expect(bad.success).toBe(false);
|
expect(bad.success).toBe(false);
|
||||||
@@ -66,23 +71,38 @@ describe("CreateIssuedOrderSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createdIds: number[] = [];
|
const createdIds: number[] = [];
|
||||||
const createdCustomerIds: number[] = [];
|
const createdSupplierIds: number[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
// Orders first — the supplier FK is onDelete Restrict.
|
||||||
for (const id of createdIds)
|
for (const id of createdIds)
|
||||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||||
for (const id of createdCustomerIds)
|
for (const id of createdSupplierIds)
|
||||||
await prisma.customers.deleteMany({ where: { id } });
|
await prisma.sklad_suppliers.deleteMany({ where: { id } });
|
||||||
createdIds.length = 0;
|
createdIds.length = 0;
|
||||||
createdCustomerIds.length = 0;
|
createdSupplierIds.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function makeCustomer() {
|
async function makeSupplier(
|
||||||
const c = await prisma.customers.create({
|
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
|
||||||
data: { name: "Dodavatel s.r.o." },
|
) {
|
||||||
|
const s = await prisma.sklad_suppliers.create({
|
||||||
|
data: {
|
||||||
|
name: data.name ?? "io_test_Dodavatel s.r.o.",
|
||||||
|
ico: data.ico ?? null,
|
||||||
|
is_active: data.is_active ?? true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
createdCustomerIds.push(c.id);
|
createdSupplierIds.push(s.id);
|
||||||
return c;
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
|
||||||
|
async function mkIssued(input: IssuedOrderInput = {}) {
|
||||||
|
const res = await createIssuedOrder(input);
|
||||||
|
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
|
||||||
|
createdIds.push(res.id);
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||||
@@ -120,18 +140,18 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createIssuedOrder", () => {
|
describe("createIssuedOrder", () => {
|
||||||
it("defaults to draft with NO PO number, stores items", async () => {
|
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
|
||||||
const c = await makeCustomer();
|
const s = await makeSupplier();
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
customer_id: c.id,
|
supplier_id: s.id,
|
||||||
items: [
|
items: [
|
||||||
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
createdIds.push(order.id);
|
|
||||||
// Deferred numbering: a draft carries no number.
|
// Deferred numbering: a draft carries no number.
|
||||||
expect(order.po_number).toBeNull();
|
expect(order.po_number).toBeNull();
|
||||||
expect(order.status).toBe("draft");
|
expect(order.status).toBe("draft");
|
||||||
|
expect(order.supplier_id).toBe(s.id);
|
||||||
const items = await prisma.issued_order_items.findMany({
|
const items = await prisma.issued_order_items.findMany({
|
||||||
where: { issued_order_id: order.id },
|
where: { issued_order_id: order.id },
|
||||||
});
|
});
|
||||||
@@ -141,17 +161,20 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const order = await createIssuedOrder({ status: "sent" });
|
const order = await mkIssued({ status: "sent" });
|
||||||
createdIds.push(order.id);
|
|
||||||
expect(order.po_number).toBe(before);
|
expect(order.po_number).toBe(before);
|
||||||
expect(order.status).toBe("sent");
|
expect(order.status).toBe("sent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
|
||||||
|
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||||
const order = await createIssuedOrder({});
|
const order = await mkIssued({});
|
||||||
createdIds.push(order.id);
|
|
||||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
expect("error" in ok).toBe(false);
|
expect("error" in ok).toBe(false);
|
||||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -159,10 +182,9 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("locks items once confirmed", async () => {
|
it("locks items once confirmed", async () => {
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
||||||
});
|
});
|
||||||
createdIds.push(order.id);
|
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
await updateIssuedOrder(order.id, {
|
await updateIssuedOrder(order.id, {
|
||||||
@@ -174,22 +196,28 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
expect(items[0].description).toBe("A");
|
expect(items[0].description).toBe("A");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a nonexistent supplier_id on update", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrder", () => {
|
describe("getIssuedOrder", () => {
|
||||||
it("returns customer_name and valid_transitions", async () => {
|
it("returns supplier, supplier_name and valid_transitions", async () => {
|
||||||
const c = await makeCustomer();
|
const s = await makeSupplier();
|
||||||
const order = await createIssuedOrder({ customer_id: c.id });
|
const order = await mkIssued({ supplier_id: s.id });
|
||||||
createdIds.push(order.id);
|
|
||||||
const detail = await getIssuedOrder(order.id);
|
const detail = await getIssuedOrder(order.id);
|
||||||
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
|
||||||
|
expect(detail?.supplier?.id).toBe(s.id);
|
||||||
expect(detail?.valid_transitions).toContain("sent");
|
expect(detail?.valid_transitions).toContain("sent");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteIssuedOrder", () => {
|
describe("deleteIssuedOrder", () => {
|
||||||
it("deletes, cascades items, frees the latest number", async () => {
|
it("deletes, cascades items, frees the latest number", async () => {
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
||||||
});
|
});
|
||||||
// Finalize so the order has a real (consumed) number to free on delete.
|
// Finalize so the order has a real (consumed) number to free on delete.
|
||||||
@@ -215,8 +243,7 @@ describe("deleteIssuedOrder", () => {
|
|||||||
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
||||||
// so the PO number matches the prior year's current highest.
|
// so the PO number matches the prior year's current highest.
|
||||||
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
||||||
const order = await createIssuedOrder({ po_number: poNumber });
|
const order = await mkIssued({ po_number: poNumber });
|
||||||
createdIds.push(order.id);
|
|
||||||
// Backdate creation into the prior year so delete derives that year.
|
// Backdate creation into the prior year so delete derives that year.
|
||||||
await prisma.issued_orders.update({
|
await prisma.issued_orders.update({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
@@ -234,9 +261,8 @@ describe("deleteIssuedOrder", () => {
|
|||||||
|
|
||||||
describe("listIssuedOrders month filter", () => {
|
describe("listIssuedOrders month filter", () => {
|
||||||
it("returns only orders whose order_date is in the given month", async () => {
|
it("returns only orders whose order_date is in the given month", async () => {
|
||||||
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
|
const inMonth = await mkIssued({ order_date: "2026-03-15" });
|
||||||
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
|
const outMonth = await mkIssued({ order_date: "2026-04-15" });
|
||||||
createdIds.push(inMonth.id, outMonth.id);
|
|
||||||
const res = await listIssuedOrders({
|
const res = await listIssuedOrders({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@@ -252,6 +278,142 @@ describe("listIssuedOrders month filter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Route-level: suppliers lookup endpoint + supplier validation */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
let noPermToken = "";
|
||||||
|
let noPermUserId = 0;
|
||||||
|
let noPermRoleId = 0;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(issuedOrdersRoutes, {
|
||||||
|
prefix: "/api/admin/issued-orders",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = generateToken({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A role with NO orders permissions, to prove the lookup endpoint is gated.
|
||||||
|
const noPermRole = await prisma.roles.create({
|
||||||
|
data: {
|
||||||
|
name: "io_test_no_orders",
|
||||||
|
display_name: "Test No Orders",
|
||||||
|
description: "Test role without orders permissions",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noPermRoleId = noPermRole.id;
|
||||||
|
const noPermUser = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: "io_test_noperm",
|
||||||
|
email: "io_test_noperm@test.local",
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "No",
|
||||||
|
last_name: "Orders",
|
||||||
|
is_active: true,
|
||||||
|
role_id: noPermRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noPermUserId = noPermUser.id;
|
||||||
|
noPermToken = generateToken({
|
||||||
|
id: noPermUser.id,
|
||||||
|
username: noPermUser.username,
|
||||||
|
roleName: noPermRole.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (noPermUserId)
|
||||||
|
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
|
||||||
|
if (noPermRoleId)
|
||||||
|
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/issued-orders/suppliers", () => {
|
||||||
|
it("returns active suppliers only (with the picker fields)", async () => {
|
||||||
|
const active = await makeSupplier({
|
||||||
|
name: "io_test_Aktivní dodavatel",
|
||||||
|
ico: "12345678",
|
||||||
|
});
|
||||||
|
const inactive = await makeSupplier({
|
||||||
|
name: "io_test_Neaktivní dodavatel",
|
||||||
|
is_active: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/issued-orders/suppliers",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const ids = body.data.map((s: { id: number }) => s.id);
|
||||||
|
expect(ids).toContain(active.id);
|
||||||
|
expect(ids).not.toContain(inactive.id);
|
||||||
|
const row = body.data.find((s: { id: number }) => s.id === active.id);
|
||||||
|
expect(row.name).toBe("io_test_Aktivní dodavatel");
|
||||||
|
expect(row.ico).toBe("12345678");
|
||||||
|
// Lightweight lookup shape — all picker fields present.
|
||||||
|
expect(Object.keys(row).sort()).toEqual(
|
||||||
|
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is denied (403) to a user with no orders permissions", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/issued-orders/suppliers",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/issued-orders supplier validation", () => {
|
||||||
|
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/issued-orders",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { supplier_id: 99999999 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toBe("Dodavatel nenalezen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* PDF render */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
describe("renderIssuedOrderHtml", () => {
|
describe("renderIssuedOrderHtml", () => {
|
||||||
const order = {
|
const order = {
|
||||||
po_number: "26720001",
|
po_number: "26720001",
|
||||||
@@ -278,11 +440,19 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
|
|
||||||
const issuer = { name: "Jan Novák" };
|
const issuer = { name: "Jan Novák" };
|
||||||
|
|
||||||
|
// sklad_suppliers shape: single Text address blob + ico/dic columns.
|
||||||
|
const supplier = {
|
||||||
|
name: "Dodavatel s.r.o.",
|
||||||
|
ico: "12345678",
|
||||||
|
dic: "CZ12345678",
|
||||||
|
address: "Průmyslová 5\n190 00 Praha",
|
||||||
|
};
|
||||||
|
|
||||||
it("renders the PO number, items, and both party names (PO direction)", () => {
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
{ name: "Dodavatel s.r.o." },
|
supplier,
|
||||||
{ company_name: "Naše firma" },
|
{ company_name: "Naše firma" },
|
||||||
"cs",
|
"cs",
|
||||||
issuer,
|
issuer,
|
||||||
@@ -291,14 +461,46 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Materiál");
|
expect(html).toContain("Materiál");
|
||||||
// Optional item sub-line.
|
// Optional item sub-line.
|
||||||
expect(html).toContain("Detailní popis položky");
|
expect(html).toContain("Detailní popis položky");
|
||||||
// PO direction: the customer record is the Dodavatel (supplier), our
|
// PO direction: the sklad_suppliers record is the Dodavatel (supplier),
|
||||||
// company is the Odběratel (buyer).
|
// our company is the Odběratel (buyer).
|
||||||
expect(html).toContain("Dodavatel s.r.o.");
|
expect(html).toContain("Dodavatel s.r.o.");
|
||||||
expect(html).toContain("Naše firma");
|
expect(html).toContain("Naše firma");
|
||||||
expect(html).toContain("Dodavatel");
|
expect(html).toContain("Dodavatel");
|
||||||
expect(html).toContain("Odběratel");
|
expect(html).toContain("Odběratel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
supplier,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
// The single Text address blob becomes one address line per newline.
|
||||||
|
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
|
||||||
|
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
|
||||||
|
expect(html).toContain("IČ: 12345678");
|
||||||
|
expect(html).toContain("DIČ: CZ12345678");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(html).toContain(
|
||||||
|
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("IČ: ");
|
||||||
|
expect(html).not.toContain("DIČ: ");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips script tags from notes", () => {
|
it("strips script tags from notes", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
expect(html).not.toContain("<script>");
|
expect(html).not.toContain("<script>");
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||||
createdIssuedIds.push(o.id);
|
createdIssuedIds.push(o.id);
|
||||||
return o.id;
|
return o.id;
|
||||||
};
|
};
|
||||||
@@ -195,6 +196,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
createdIssuedIds.push(draft.id);
|
createdIssuedIds.push(draft.id);
|
||||||
|
|
||||||
const sent = await createIssuedOrder({
|
const sent = await createIssuedOrder({
|
||||||
@@ -207,6 +210,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in sent)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||||
createdIssuedIds.push(sent.id);
|
createdIssuedIds.push(sent.id);
|
||||||
|
|
||||||
const next = await createIssuedOrder({
|
const next = await createIssuedOrder({
|
||||||
@@ -218,6 +223,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in next)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||||
createdIssuedIds.push(next.id);
|
createdIssuedIds.push(next.id);
|
||||||
|
|
||||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
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 prisma from "../config/database";
|
||||||
import { createOrder, listOrders } from "../services/orders.service";
|
import { config } from "../config/env";
|
||||||
|
import ordersRoutes from "../routes/admin/orders";
|
||||||
|
import {
|
||||||
|
createOrder,
|
||||||
|
listOrders,
|
||||||
|
getOrder,
|
||||||
|
getOrderAttachment,
|
||||||
|
} from "../services/orders.service";
|
||||||
|
|
||||||
const createdOrderIds: number[] = [];
|
const createdOrderIds: number[] = [];
|
||||||
const createdCustomerIds: number[] = [];
|
const createdCustomerIds: number[] = [];
|
||||||
@@ -80,6 +91,166 @@ describe("listOrders search filter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("listOrders month filter", () => {
|
||||||
it("returns only orders whose created_at is in the given month", async () => {
|
it("returns only orders whose created_at is in the given month", async () => {
|
||||||
const customer = await makeCustomer();
|
const customer = await makeCustomer();
|
||||||
|
|||||||
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 Fastify from "fastify";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import warehouseRoutes from "../routes/admin/warehouse";
|
import warehouseRoutes from "../routes/admin/warehouse";
|
||||||
|
import { nasInvoicesManager } from "../services/nas-financials-manager";
|
||||||
import { securityHeaders } from "../middleware/security";
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -879,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
|
// 6. Issue flow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import QRCode from "qrcode";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
@@ -134,6 +135,28 @@ export default function DashProfile({
|
|||||||
// locks <html> scroll like the kit dialogs.
|
// locks <html> scroll like the kit dialogs.
|
||||||
useDialogScrollLock(show2FASetup);
|
useDialogScrollLock(show2FASetup);
|
||||||
|
|
||||||
|
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
|
||||||
|
// URI to an external QR service: the production CSP (img-src) blocks it and
|
||||||
|
// it would leak the TOTP secret to a third party. A data: URL is allowed by
|
||||||
|
// the CSP. Derived via useQuery (not an effect) per repo conventions.
|
||||||
|
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
||||||
|
queryKey: ["totp", "qr", totpQrUri],
|
||||||
|
enabled: !!totpQrUri,
|
||||||
|
staleTime: Infinity,
|
||||||
|
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
||||||
|
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
||||||
|
gcTime: 0,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("DashProfile: generování QR kódu selhalo", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [formData, setFormData] = useState<ProfileFormData>({
|
const [formData, setFormData] = useState<ProfileFormData>({
|
||||||
username: "",
|
username: "",
|
||||||
@@ -549,11 +572,11 @@ export default function DashProfile({
|
|||||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||||
Authy, Microsoft Authenticator apod.)
|
Authy, Microsoft Authenticator apod.)
|
||||||
</Typography>
|
</Typography>
|
||||||
{totpQrUri && (
|
{totpQrUri && totpQrDataUrl && (
|
||||||
<Box sx={{ textAlign: "center", mb: 2 }}>
|
<Box sx={{ textAlign: "center", mb: 2 }}>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
src={totpQrDataUrl}
|
||||||
alt="TOTP QR Code"
|
alt="TOTP QR Code"
|
||||||
sx={{
|
sx={{
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -561,10 +584,23 @@ export default function DashProfile({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
|
// The QR must stay scannable in dark mode — keep it on a
|
||||||
|
// white tile instead of inheriting the dark paper bg.
|
||||||
|
bgcolor: "common.white",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{totpQrUri && totpQrFailed && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="warning.main"
|
||||||
|
sx={{ mb: 2, textAlign: "center" }}
|
||||||
|
>
|
||||||
|
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
|
||||||
|
aplikace ručně.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{totpSecret && (
|
{totpSecret && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
||||||
|
import { queryClient } from "../lib/queryClient";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
remember,
|
remember,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
|
||||||
|
// so anything cached from a previously logged-in user would be
|
||||||
|
// served to this one — drop the whole cache on every login.
|
||||||
|
queryClient.clear();
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUserRef.current = mapUser(data.data.user);
|
||||||
@@ -260,19 +265,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
) => {
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/login/totp`, {
|
// Backup codes have their own endpoint + schema (8-char codes would
|
||||||
method: "POST",
|
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
|
||||||
headers: { "Content-Type": "application/json" },
|
// complete the same login flow: tokens issued + refresh cookie set.
|
||||||
credentials: "include",
|
const response = await fetch(
|
||||||
body: JSON.stringify({
|
isBackup
|
||||||
login_token: loginToken,
|
? `${API_BASE}/totp/backup-verify`
|
||||||
totp_code: code,
|
: `${API_BASE}/login/totp`,
|
||||||
remember_me: remember,
|
{
|
||||||
isBackup,
|
method: "POST",
|
||||||
}),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
credentials: "include",
|
||||||
|
body: JSON.stringify(
|
||||||
|
isBackup
|
||||||
|
? {
|
||||||
|
login_token: loginToken,
|
||||||
|
backup_code: code,
|
||||||
|
remember_me: remember,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
login_token: loginToken,
|
||||||
|
totp_code: code,
|
||||||
|
remember_me: remember,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Same reason as in login(): the cache may hold the previous
|
||||||
|
// user's data under user-agnostic keys.
|
||||||
|
queryClient.clear();
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUserRef.current = mapUser(data.data.user);
|
||||||
@@ -312,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
clearTimeout(refreshTimeoutRef.current);
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
// The React Query cache outlives the session — without this, the next
|
||||||
|
// user to log in on this browser sees the previous user's cached
|
||||||
|
// dashboards/lists until each query refetches.
|
||||||
|
queryClient.clear();
|
||||||
}
|
}
|
||||||
}, [getAccessTokenFn]);
|
}, [getAccessTokenFn]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
calcFormWorkMinutes,
|
calcFormWorkMinutes,
|
||||||
calculateWorkMinutes,
|
calculateWorkMinutes,
|
||||||
|
combineDatetime,
|
||||||
getDatePart,
|
getDatePart,
|
||||||
getTimePart,
|
getTimePart,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -133,9 +134,6 @@ interface DeleteConfirmState {
|
|||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
const combineDatetime = (date: string, time: string): string | null =>
|
|
||||||
date && time ? `${date}T${time}:00` : null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute per-user totals from raw attendance records.
|
* Compute per-user totals from raw attendance records.
|
||||||
* This replaces the server-side `user_totals` that the PHP backend returned.
|
* This replaces the server-side `user_totals` that the PHP backend returned.
|
||||||
@@ -869,7 +867,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
try {
|
try {
|
||||||
const [yearStr, monthStr] = month.split("-");
|
const [yearStr, monthStr] = month.split("-");
|
||||||
|
|
||||||
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`;
|
// The KPI cards + summary totals are computed from THIS fetch — it
|
||||||
|
// must cover the complete month (the server allows limit up to 2000
|
||||||
|
// for exactly this view; 2000 ≈ 64 employees × 31 days).
|
||||||
|
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=2000`;
|
||||||
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
|
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
|
||||||
|
|
||||||
const [recordsResponse, balancesResponse] = await Promise.all([
|
const [recordsResponse, balancesResponse] = await Promise.all([
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
|||||||
export interface IssuedOrder {
|
export interface IssuedOrder {
|
||||||
id: number;
|
id: number;
|
||||||
po_number: string | null;
|
po_number: string | null;
|
||||||
customer_id: number | null;
|
supplier_id: number | null;
|
||||||
customer_name: string | null;
|
supplier_name: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
order_date: string | null;
|
order_date: string | null;
|
||||||
@@ -14,6 +14,17 @@ export interface IssuedOrder {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
|
||||||
|
export interface Supplier {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ico: string | null;
|
||||||
|
dic: string | null;
|
||||||
|
address: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssuedOrderItem {
|
export interface IssuedOrderItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -37,10 +48,19 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
internal_notes: string | null;
|
internal_notes: string | null;
|
||||||
items: IssuedOrderItem[];
|
items: IssuedOrderItem[];
|
||||||
customer: Record<string, unknown> | null;
|
supplier: Record<string, unknown> | null;
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
|
||||||
|
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
|
||||||
|
export const issuedOrderSuppliersOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["issued-orders", "suppliers"],
|
||||||
|
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
|
||||||
|
staleTime: 2 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
export const issuedOrderListOptions = (filters: {
|
export const issuedOrderListOptions = (filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
|
|||||||
@@ -86,3 +86,13 @@ export const require2FAOptions = () =>
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
|
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-user 2FA enrollment status (users.totp_enabled) — NOT the company-wide
|
||||||
|
// require_2fa policy above. Mutations that enable/disable 2FA must invalidate
|
||||||
|
// the broad ["totp"] domain key.
|
||||||
|
export const totpStatusOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["totp", "status"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ totp_enabled: boolean }>("/api/admin/totp/status"),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
export interface TripVehicle {
|
export interface TripVehicle {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -59,7 +59,9 @@ export const tripListOptions = (filters: {
|
|||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
return paginatedJsonQuery<BackendTrip>(
|
||||||
|
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ export const tripHistoryOptions = (filters: {
|
|||||||
month?: string;
|
month?: string;
|
||||||
vehicleId?: number;
|
vehicleId?: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
@@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: {
|
|||||||
month: filters.month,
|
month: filters.month,
|
||||||
vehicleId: filters.vehicleId,
|
vehicleId: filters.vehicleId,
|
||||||
userId: filters.userId,
|
userId: filters.userId,
|
||||||
|
page: filters.page,
|
||||||
|
perPage: filters.perPage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: {
|
|||||||
if (filters.vehicleId)
|
if (filters.vehicleId)
|
||||||
params.set("vehicle_id", String(filters.vehicleId));
|
params.set("vehicle_id", String(filters.vehicleId));
|
||||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||||
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
return paginatedJsonQuery<BackendTrip>(
|
||||||
|
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface TripStats {
|
||||||
|
count: number;
|
||||||
|
total_km: number;
|
||||||
|
business_km: number;
|
||||||
|
private_km: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count/km totals over the WHOLE filtered set (not one page) — hits
|
||||||
|
// /trips/stats with the same filters the lists use. `month` accepts either a
|
||||||
|
// numeric month (paired with `year`, TripsAdmin style) or a combined
|
||||||
|
// "YYYY-MM" string (TripsHistory style); the server supports both.
|
||||||
|
export const tripStatsOptions = (filters: {
|
||||||
|
month?: number | string;
|
||||||
|
year?: number;
|
||||||
|
vehicleId?: number;
|
||||||
|
userId?: number;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"trips",
|
||||||
|
"stats",
|
||||||
|
{
|
||||||
|
month: filters.month,
|
||||||
|
year: filters.year,
|
||||||
|
vehicleId: filters.vehicleId,
|
||||||
|
userId: filters.userId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.month) params.set("month", String(filters.month));
|
||||||
|
if (filters.year) params.set("year", String(filters.year));
|
||||||
|
if (filters.vehicleId)
|
||||||
|
params.set("vehicle_id", String(filters.vehicleId));
|
||||||
|
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||||
|
const qs = params.toString();
|
||||||
|
return jsonQuery<TripStats>(
|
||||||
|
`/api/admin/trips/stats${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { todayLocalStr } from "../utils/formatters";
|
import { todayLocalStr } from "../utils/formatters";
|
||||||
|
import { combineDatetime } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -41,6 +42,24 @@ interface CreateForm {
|
|||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire payload for POST /attendance (CreateAttendanceSchema). The raw form
|
||||||
|
* above is NOT sent directly: its separate date+time inputs are combined into
|
||||||
|
* "YYYY-MM-DDTHH:MM:00" datetimes (same semantics as useAttendanceAdmin's
|
||||||
|
* create modal), and unset optional fields are null — never "".
|
||||||
|
*/
|
||||||
|
interface CreatePayload {
|
||||||
|
user_id: number;
|
||||||
|
shift_date: string;
|
||||||
|
leave_type: string;
|
||||||
|
notes: string | null;
|
||||||
|
leave_hours?: number;
|
||||||
|
arrival_time?: string | null;
|
||||||
|
departure_time?: string | null;
|
||||||
|
break_start?: string | null;
|
||||||
|
break_end?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AttendanceCreate() {
|
export default function AttendanceCreate() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -52,7 +71,7 @@ export default function AttendanceCreate() {
|
|||||||
}));
|
}));
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const createMutation = useApiMutation<CreateForm, { message?: string }>({
|
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "users"],
|
invalidate: ["attendance", "users"],
|
||||||
@@ -88,7 +107,36 @@ export default function AttendanceCreate() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createMutation.mutateAsync(form);
|
const isLeave = form.leave_type !== "work";
|
||||||
|
const payload: CreatePayload = {
|
||||||
|
user_id: Number(form.user_id),
|
||||||
|
shift_date: form.shift_date,
|
||||||
|
leave_type: form.leave_type,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLeave) {
|
||||||
|
payload.leave_hours = form.leave_hours || 8;
|
||||||
|
} else {
|
||||||
|
payload.arrival_time = combineDatetime(
|
||||||
|
form.arrival_date,
|
||||||
|
form.arrival_time,
|
||||||
|
);
|
||||||
|
payload.departure_time = combineDatetime(
|
||||||
|
form.departure_date,
|
||||||
|
form.departure_time,
|
||||||
|
);
|
||||||
|
payload.break_start = combineDatetime(
|
||||||
|
form.break_start_date,
|
||||||
|
form.break_start_time,
|
||||||
|
);
|
||||||
|
payload.break_end = combineDatetime(
|
||||||
|
form.break_end_date,
|
||||||
|
form.break_end_time,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createMutation.mutateAsync(payload);
|
||||||
alert.success(result?.message || "Uloženo");
|
alert.success(result?.message || "Uloženo");
|
||||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { dashboardOptions } from "../lib/queries/dashboard";
|
import { dashboardOptions } from "../lib/queries/dashboard";
|
||||||
import { require2FAOptions } from "../lib/queries/settings";
|
import { totpStatusOptions } from "../lib/queries/settings";
|
||||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
||||||
@@ -86,9 +86,11 @@ export default function Dashboard() {
|
|||||||
const { data: dashDataRaw, isPending: dashLoading } =
|
const { data: dashDataRaw, isPending: dashLoading } =
|
||||||
useQuery(dashboardOptions());
|
useQuery(dashboardOptions());
|
||||||
const dashData = dashDataRaw as DashData | undefined;
|
const dashData = dashDataRaw as DashData | undefined;
|
||||||
|
// Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide
|
||||||
|
// require_2fa policy flag (the banner below uses user.require2FA for that).
|
||||||
const { data: totpData, isPending: totpLoading } =
|
const { data: totpData, isPending: totpLoading } =
|
||||||
useQuery(require2FAOptions());
|
useQuery(totpStatusOptions());
|
||||||
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
|
const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled;
|
||||||
|
|
||||||
const punchMutation = useApiMutation<
|
const punchMutation = useApiMutation<
|
||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
@@ -176,6 +178,7 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
@@ -206,6 +209,7 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices";
|
|||||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||||
import { bankAccountsOptions } from "../lib/queries/common";
|
import { bankAccountsOptions } from "../lib/queries/common";
|
||||||
import { jsonQuery } from "../lib/apiAdapter";
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
numberOr,
|
||||||
|
todayLocalStr,
|
||||||
|
} from "../utils/formatters";
|
||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -686,7 +691,7 @@ export default function InvoiceDetail() {
|
|||||||
tax_date: normalizeDateStr(inv.tax_date),
|
tax_date: normalizeDateStr(inv.tax_date),
|
||||||
currency: inv.currency || "CZK",
|
currency: inv.currency || "CZK",
|
||||||
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
||||||
vat_rate: Number(inv.vat_rate) || 21,
|
vat_rate: numberOr(inv.vat_rate, 21),
|
||||||
payment_method: inv.payment_method || "Příkazem",
|
payment_method: inv.payment_method || "Příkazem",
|
||||||
constant_symbol: inv.constant_symbol || "0308",
|
constant_symbol: inv.constant_symbol || "0308",
|
||||||
issued_by: inv.issued_by || "",
|
issued_by: inv.issued_by || "",
|
||||||
@@ -725,7 +730,11 @@ export default function InvoiceDetail() {
|
|||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
vat_rate: Number(inv.vat_rate) || 21,
|
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
||||||
|
// column returned by the detail endpoint) — falling back to the
|
||||||
|
// invoice-level rate only when the line carries none. Hydrating
|
||||||
|
// from the invoice rate corrupted mixed-rate invoices on re-save.
|
||||||
|
vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (mappedItems.length > 0) {
|
if (mappedItems.length > 0) {
|
||||||
@@ -781,8 +790,10 @@ export default function InvoiceDetail() {
|
|||||||
// Pre-fill from order
|
// Pre-fill from order
|
||||||
if (fromOrderId && orderDataQuery.data) {
|
if (fromOrderId && orderDataQuery.data) {
|
||||||
const order = orderDataQuery.data;
|
const order = orderDataQuery.data;
|
||||||
const vatRate =
|
const vatRate = numberOr(
|
||||||
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
|
order.vat_rate,
|
||||||
|
companySettings?.default_vat_rate ?? 21,
|
||||||
|
);
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
customer_id: order.customer_id as number,
|
customer_id: order.customer_id as number,
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { jsonQuery } from "../lib/apiAdapter";
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
import {
|
||||||
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
|
issuedOrderDetailOptions,
|
||||||
|
issuedOrderSuppliersOptions,
|
||||||
|
type Supplier,
|
||||||
|
} from "../lib/queries/issued-orders";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
import { formatCurrency, todayLocalStr } from "../utils/formatters";
|
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
|
||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -58,7 +61,7 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
CustomerPicker,
|
SupplierPicker,
|
||||||
headerActionsSx,
|
headerActionsSx,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
import {
|
import {
|
||||||
@@ -157,8 +160,8 @@ interface OrderItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface OrderForm {
|
interface OrderForm {
|
||||||
customer_id: number | null;
|
supplier_id: number | null;
|
||||||
customer_name: string;
|
supplier_name: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
@@ -506,8 +509,8 @@ export default function IssuedOrderDetail() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [form, setForm] = useState<OrderForm>({
|
const [form, setForm] = useState<OrderForm>({
|
||||||
customer_id: null,
|
supplier_id: null,
|
||||||
customer_name: "",
|
supplier_name: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
apply_vat: true,
|
apply_vat: true,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
@@ -551,8 +554,34 @@ export default function IssuedOrderDetail() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// ─── Queries ───
|
// ─── Queries ───
|
||||||
const customersQuery = useQuery(offerCustomersOptions());
|
const suppliersQuery = useQuery(issuedOrderSuppliersOptions());
|
||||||
const customers = customersQuery.data ?? [];
|
|
||||||
|
// The lookup returns ACTIVE suppliers only, but a saved order may reference
|
||||||
|
// a since-deactivated one — without a fallback option the picker would
|
||||||
|
// resolve to nothing and render the counterparty blank. The fallback is
|
||||||
|
// built from the hydrated form state (supplier_name comes from the detail
|
||||||
|
// response), so the name stays visible and a re-save keeps the same id.
|
||||||
|
const suppliers = useMemo<Supplier[]>(() => {
|
||||||
|
const activeSuppliers = suppliersQuery.data ?? [];
|
||||||
|
if (
|
||||||
|
form.supplier_id != null &&
|
||||||
|
!activeSuppliers.some((s: Supplier) => s.id === form.supplier_id)
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
...activeSuppliers,
|
||||||
|
{
|
||||||
|
id: form.supplier_id,
|
||||||
|
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
|
||||||
|
ico: null,
|
||||||
|
dic: null,
|
||||||
|
address: null,
|
||||||
|
email: null,
|
||||||
|
phone: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return activeSuppliers;
|
||||||
|
}, [suppliersQuery.data, form.supplier_id, form.supplier_name]);
|
||||||
|
|
||||||
const companySettings = useQuery(companySettingsOptions()).data;
|
const companySettings = useQuery(companySettingsOptions()).data;
|
||||||
// Configurable currency list from company settings (falls back to the
|
// Configurable currency list from company settings (falls back to the
|
||||||
@@ -576,16 +605,16 @@ export default function IssuedOrderDetail() {
|
|||||||
// ─── Edit mode: hydrate form from detail (once) ───
|
// ─── Edit mode: hydrate form from detail (once) ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit || dataReady) return;
|
if (!isEdit || dataReady) return;
|
||||||
if (detailQuery.isLoading || customersQuery.isLoading) return;
|
if (detailQuery.isLoading || suppliersQuery.isLoading) return;
|
||||||
if (!detailQuery.data) return;
|
if (!detailQuery.data) return;
|
||||||
|
|
||||||
const d = detailQuery.data;
|
const d = detailQuery.data;
|
||||||
setForm({
|
setForm({
|
||||||
customer_id: d.customer_id ?? null,
|
supplier_id: d.supplier_id ?? null,
|
||||||
customer_name: d.customer_name ?? "",
|
supplier_name: d.supplier_name ?? "",
|
||||||
currency: d.currency || "CZK",
|
currency: d.currency || "CZK",
|
||||||
apply_vat: d.apply_vat !== false,
|
apply_vat: d.apply_vat !== false,
|
||||||
vat_rate: Number(d.vat_rate) || 21,
|
vat_rate: numberOr(d.vat_rate, 21),
|
||||||
order_date: normalizeDateStr(d.order_date),
|
order_date: normalizeDateStr(d.order_date),
|
||||||
delivery_date: normalizeDateStr(d.delivery_date),
|
delivery_date: normalizeDateStr(d.delivery_date),
|
||||||
language: d.language || "cs",
|
language: d.language || "cs",
|
||||||
@@ -605,10 +634,10 @@ export default function IssuedOrderDetail() {
|
|||||||
id: it.id,
|
id: it.id,
|
||||||
description: it.description || "",
|
description: it.description || "",
|
||||||
item_description: it.item_description || "",
|
item_description: it.item_description || "",
|
||||||
quantity: Number(it.quantity) || 1,
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
vat_rate: Number(it.vat_rate) || Number(d.vat_rate) || 21,
|
vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (mapped.length > 0) setItems(mapped);
|
if (mapped.length > 0) setItems(mapped);
|
||||||
@@ -619,13 +648,13 @@ export default function IssuedOrderDetail() {
|
|||||||
dataReady,
|
dataReady,
|
||||||
detailQuery.isLoading,
|
detailQuery.isLoading,
|
||||||
detailQuery.data,
|
detailQuery.data,
|
||||||
customersQuery.isLoading,
|
suppliersQuery.isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ─── Create mode: set the previewed PO number + default issued_by ───
|
// ─── Create mode: set the previewed PO number + default issued_by ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit || dataReady) return;
|
if (isEdit || dataReady) return;
|
||||||
if (nextNumberQuery.isLoading || customersQuery.isLoading) return;
|
if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return;
|
||||||
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
|
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
|
||||||
setDataReady(true);
|
setDataReady(true);
|
||||||
}, [
|
}, [
|
||||||
@@ -633,7 +662,7 @@ export default function IssuedOrderDetail() {
|
|||||||
dataReady,
|
dataReady,
|
||||||
nextNumberQuery.isLoading,
|
nextNumberQuery.isLoading,
|
||||||
nextNumberQuery.data,
|
nextNumberQuery.data,
|
||||||
customersQuery.isLoading,
|
suppliersQuery.isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keep the displayed PO number in sync once it becomes available — a draft
|
// Keep the displayed PO number in sync once it becomes available — a draft
|
||||||
@@ -715,20 +744,20 @@ export default function IssuedOrderDetail() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectCustomer = (id: number | null) => {
|
const selectSupplier = (id: number | null) => {
|
||||||
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
|
const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null;
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
customer_id: id,
|
supplier_id: id,
|
||||||
customer_name: c?.name || "",
|
supplier_name: s?.name || "",
|
||||||
}));
|
}));
|
||||||
setErrors((prev) => ({ ...prev, customer_id: "" }));
|
setErrors((prev) => ({ ...prev, supplier_id: "" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Submit (create + edit) ───
|
// ─── Submit (create + edit) ───
|
||||||
const handleSubmit = async (targetStatus?: string) => {
|
const handleSubmit = async (targetStatus?: string) => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
|
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
||||||
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
||||||
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
||||||
newErrors.items = "Přidejte alespoň jednu položku";
|
newErrors.items = "Přidejte alespoň jednu položku";
|
||||||
@@ -740,7 +769,7 @@ export default function IssuedOrderDetail() {
|
|||||||
setSavingAction(targetStatus ?? "save");
|
setSavingAction(targetStatus ?? "save");
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
customer_id: form.customer_id,
|
supplier_id: form.supplier_id,
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
vat_rate: form.vat_rate,
|
vat_rate: form.vat_rate,
|
||||||
apply_vat: form.apply_vat,
|
apply_vat: form.apply_vat,
|
||||||
@@ -831,6 +860,12 @@ export default function IssuedOrderDetail() {
|
|||||||
try {
|
try {
|
||||||
await statusMutation.mutateAsync({ status: newStatus });
|
await statusMutation.mutateAsync({ status: newStatus });
|
||||||
alert.success("Stav byl změněn");
|
alert.success("Stav byl změněn");
|
||||||
|
// Mirror the new status into the form (same as handleSubmit's success
|
||||||
|
// path): the one-shot hydration won't re-run (dataReady stays true), so
|
||||||
|
// without this the StatusChip, the `editable` flag and the delete-button
|
||||||
|
// gate would keep reading the stale status until a full remount. The PO
|
||||||
|
// number is repopulated by the detail-query sync effect.
|
||||||
|
setForm((prev) => ({ ...prev, status: newStatus }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert.error(err instanceof Error ? err.message : "Chyba připojení");
|
alert.error(err instanceof Error ? err.message : "Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1102,13 +1137,13 @@ export default function IssuedOrderDetail() {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Dodavatel" error={errors.customer_id} required>
|
<Field label="Dodavatel" error={errors.supplier_id} required>
|
||||||
<CustomerPicker
|
<SupplierPicker
|
||||||
customers={customers}
|
suppliers={suppliers}
|
||||||
value={form.customer_id}
|
value={form.supplier_id}
|
||||||
onChange={selectCustomer}
|
onChange={selectSupplier}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
error={errors.customer_id}
|
error={errors.supplier_id}
|
||||||
placeholder="Vyberte dodavatele…"
|
placeholder="Vyberte dodavatele…"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -229,10 +229,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customer_name",
|
key: "supplier_name",
|
||||||
header: "Dodavatel",
|
header: "Dodavatel",
|
||||||
width: "24%",
|
width: "24%",
|
||||||
render: (o) => o.customer_name || "—",
|
render: (o) => o.supplier_name || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|||||||
@@ -172,7 +172,13 @@ export default function Projects() {
|
|||||||
enabled: showCreate,
|
enabled: showCreate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useApiMutation<typeof createForm, { id: number }>({
|
const createMutation = useApiMutation<
|
||||||
|
Omit<typeof createForm, "start_date" | "end_date"> & {
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
},
|
||||||
|
{ id: number }
|
||||||
|
>({
|
||||||
url: () => `${API_BASE}/projects`,
|
url: () => `${API_BASE}/projects`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
|
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
|
||||||
@@ -204,7 +210,13 @@ export default function Projects() {
|
|||||||
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(createForm);
|
// Server's isoDateString.nullish() accepts null but rejects "" —
|
||||||
|
// send empty date fields as null so the create doesn't 400.
|
||||||
|
await createMutation.mutateAsync({
|
||||||
|
...createForm,
|
||||||
|
start_date: createForm.start_date || null,
|
||||||
|
end_date: createForm.end_date || null,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters";
|
|||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
tripListOptions,
|
tripListOptions,
|
||||||
|
tripStatsOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -158,12 +160,20 @@ export default function Trips() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
const { data: tripsData, isPending: tripsLoading } = useQuery(
|
// "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch
|
||||||
tripListOptions({}),
|
// exactly those; the stat cards come from the server-side stats endpoint
|
||||||
);
|
// (computed over the whole filtered set, not one page).
|
||||||
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
const { items: trips, isPending: tripsLoading } =
|
||||||
|
usePaginatedQuery<BackendTrip>(tripListOptions({ perPage: 10 }));
|
||||||
|
|
||||||
const trips = tripsData ?? [];
|
// Stat cards are a personal "this month" summary — the header subtitle
|
||||||
|
// shows the current month, so the stats query is scoped to match it.
|
||||||
|
const now = new Date();
|
||||||
|
const { data: stats } = useQuery(
|
||||||
|
tripStatsOptions({ month: now.getMonth() + 1, year: now.getFullYear() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
||||||
const vehicles = vehiclesData ?? [];
|
const vehicles = vehiclesData ?? [];
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -321,19 +331,14 @@ export default function Trips() {
|
|||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = trips.reduce(
|
const totals = {
|
||||||
(acc, t) => {
|
count: stats?.count ?? 0,
|
||||||
const dist = t.distance ?? t.end_km - t.start_km;
|
total: stats?.total_km ?? 0,
|
||||||
acc.count++;
|
business: stats?.business_km ?? 0,
|
||||||
acc.total += dist;
|
private: stats?.private_km ?? 0,
|
||||||
if (t.is_business) acc.business += dist;
|
};
|
||||||
else acc.private += dist;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ total: 0, business: 0, private: 0, count: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentTrips = trips.slice(0, 10);
|
const recentTrips = trips;
|
||||||
|
|
||||||
const columns: DataColumn<BackendTrip>[] = [
|
const columns: DataColumn<BackendTrip>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import {
|
import {
|
||||||
tripListOptions,
|
tripListOptions,
|
||||||
|
tripStatsOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
tripUsersOptions,
|
tripUsersOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
|
type TripStats,
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
@@ -28,6 +32,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
DateField,
|
DateField,
|
||||||
MonthField,
|
MonthField,
|
||||||
|
Pagination,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
@@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape user-origin strings before interpolating them into the print HTML
|
||||||
|
* (the JSX hidden-div approach escaped automatically; the string template
|
||||||
|
* must do it explicitly).
|
||||||
|
*/
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRINT_STYLES = `
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #000;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10mm;
|
||||||
|
}
|
||||||
|
.print-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
}
|
||||||
|
.print-header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.print-logo { height: 40px; width: auto; }
|
||||||
|
.print-header-text { text-align: left; }
|
||||||
|
.print-header-right { text-align: right; }
|
||||||
|
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
|
||||||
|
.print-header .company { font-size: 11px; color: #666; }
|
||||||
|
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
||||||
|
.print-header .filters { font-size: 10px; color: #666; }
|
||||||
|
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.summary-item { text-align: center; }
|
||||||
|
.summary-value { font-size: 14px; font-weight: 700; }
|
||||||
|
.summary-label { font-size: 9px; color: #666; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
||||||
|
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
|
||||||
|
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
|
||||||
|
td { font-size: 9px; }
|
||||||
|
tr:nth-child(even) { background: #f9f9f9; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
tfoot td { background: #eee; font-weight: 600; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||||
|
.badge-warning { background: #fef3c7; color: #d97706; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 5mm; }
|
||||||
|
@page { size: A4 landscape; margin: 5mm; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full print document from the fetched /trips/print data set —
|
||||||
|
* no hidden-div/innerHTML round-trip, so there is nothing to race against
|
||||||
|
* React's commit.
|
||||||
|
*/
|
||||||
|
function buildPrintHtml(opts: {
|
||||||
|
trips: Trip[];
|
||||||
|
totals: TripStats;
|
||||||
|
periodName: string;
|
||||||
|
companyName: string;
|
||||||
|
vehicleName: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
logoUrl: string;
|
||||||
|
}): string {
|
||||||
|
const { trips, totals } = opts;
|
||||||
|
|
||||||
|
const rows = trips
|
||||||
|
.map(
|
||||||
|
(trip) => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(trip.trip_date)}</td>
|
||||||
|
<td>${escapeHtml(trip.driver_name)}</td>
|
||||||
|
<td>${escapeHtml(trip.spz)}</td>
|
||||||
|
<td>${escapeHtml(trip.route_from)} → ${escapeHtml(trip.route_to)}</td>
|
||||||
|
<td class="text-right">${formatKm(trip.start_km)} - ${formatKm(trip.end_km)}</td>
|
||||||
|
<td class="text-right"><strong>${formatKm(trip.distance)} km</strong></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge ${trip.is_business ? "badge-success" : "badge-warning"}">
|
||||||
|
${trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(trip.notes || "")}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Kniha jízd - ${escapeHtml(opts.periodName)}</title>
|
||||||
|
<style>${PRINT_STYLES}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="print-header">
|
||||||
|
<div class="print-header-left">
|
||||||
|
<img src="${opts.logoUrl}" alt="" class="print-logo" />
|
||||||
|
<div class="print-header-text">
|
||||||
|
<h1>KNIHA JÍZD</h1>
|
||||||
|
<div class="company">${escapeHtml(opts.companyName)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-header-right">
|
||||||
|
<div class="period">${escapeHtml(opts.periodName)}</div>
|
||||||
|
${opts.vehicleName ? `<div class="filters">Vozidlo: ${escapeHtml(opts.vehicleName)}</div>` : ""}
|
||||||
|
${opts.userName ? `<div class="filters">Řidič: ${escapeHtml(opts.userName)}</div>` : ""}
|
||||||
|
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value">${totals.count}</div>
|
||||||
|
<div class="summary-label">Počet jízd</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value">${formatKm(totals.total_km)} km</div>
|
||||||
|
<div class="summary-label">Celkem</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value">${formatKm(totals.business_km)} km</div>
|
||||||
|
<div class="summary-label">Služební</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value">${formatKm(totals.private_km)} km</div>
|
||||||
|
<div class="summary-label">Soukromé</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70px">Datum</th>
|
||||||
|
<th style="width: 80px">Řidič</th>
|
||||||
|
<th style="width: 70px">Vozidlo</th>
|
||||||
|
<th>Trasa</th>
|
||||||
|
<th style="width: 70px" class="text-right">Stav km</th>
|
||||||
|
<th style="width: 60px" class="text-right">Vzdálenost</th>
|
||||||
|
<th style="width: 55px" class="text-center">Typ</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-right">Celkem:</td>
|
||||||
|
<td class="text-right"><strong>${formatKm(totals.total_km)} km</strong></td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const PrintIcon = (
|
const PrintIcon = (
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
|
|||||||
});
|
});
|
||||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||||
const [filterUserId, setFilterUserId] = useState("");
|
const [filterUserId, setFilterUserId] = useState("");
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
|
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
|
||||||
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
|
|||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
const companyName = companySettings?.company_name ?? "";
|
const companyName = companySettings?.company_name ?? "";
|
||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
// ONE shared filter object for the list, stats AND print requests so the
|
||||||
tripListOptions({
|
// three can never drift apart. An empty MonthField value yields
|
||||||
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
// month/year = undefined (omitted from the request), mirroring the
|
||||||
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
// `Number(...) || undefined` guard the queries always used.
|
||||||
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
const filters = {
|
||||||
userId: filterUserId ? Number(filterUserId) : undefined,
|
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
||||||
perPage: 100,
|
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
||||||
}),
|
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
||||||
);
|
userId: filterUserId ? Number(filterUserId) : undefined,
|
||||||
const trips = (tripsData ?? []).map(mapTrip);
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: tripsItems,
|
||||||
|
pagination,
|
||||||
|
isPending,
|
||||||
|
} = usePaginatedQuery<BackendTrip>(tripListOptions({ ...filters, page }));
|
||||||
|
const trips = tripsItems.map(mapTrip);
|
||||||
|
|
||||||
|
// Stat cards: totals over the WHOLE filtered set (server-side aggregate),
|
||||||
|
// not just the visible page.
|
||||||
|
const { data: stats } = useQuery(tripStatsOptions(filters));
|
||||||
|
|
||||||
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
||||||
// TIn must match the backend schema (UpdateTripSchema) shape directly —
|
// TIn must match the backend schema (UpdateTripSchema) shape directly —
|
||||||
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPeriodName = () =>
|
const getPeriodName = () =>
|
||||||
new Date(
|
filterPeriod
|
||||||
Number(filterPeriod.slice(0, 4)),
|
? new Date(
|
||||||
Number(filterPeriod.slice(5, 7)) - 1,
|
Number(filterPeriod.slice(0, 4)),
|
||||||
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
|
Number(filterPeriod.slice(5, 7)) - 1,
|
||||||
|
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
|
||||||
|
: "Všechna období";
|
||||||
const getSelectedVehicleName = () => {
|
const getSelectedVehicleName = () => {
|
||||||
if (!filterVehicleId) return null;
|
if (!filterVehicleId) return null;
|
||||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||||
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
|
|||||||
return u?.name || null;
|
return u?.name || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = async () => {
|
||||||
const periodName = getPeriodName();
|
// Open the print window SYNCHRONOUSLY, inside the click's transient
|
||||||
|
// activation — calling window.open after the network await can fall
|
||||||
|
// outside the activation window and get popup-blocked (which previously
|
||||||
|
// failed silently).
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) {
|
||||||
|
alert.error("Tisk byl zablokován prohlížečem");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
// Fetch the FULL filtered set for the printout — the list query is
|
||||||
if (printRef.current) {
|
// paginated, so printing from it would truncate to the visible page.
|
||||||
const content = printRef.current.innerHTML;
|
let printTrips: Trip[];
|
||||||
const printWindow = window.open("", "_blank");
|
let printTotals: TripStats;
|
||||||
if (!printWindow) return;
|
try {
|
||||||
printWindow.document.write(`
|
const params = new URLSearchParams();
|
||||||
<!DOCTYPE html>
|
if (filters.month) params.set("month", String(filters.month));
|
||||||
<html lang="cs">
|
if (filters.year) params.set("year", String(filters.year));
|
||||||
<head>
|
if (filters.vehicleId)
|
||||||
<meta charset="UTF-8">
|
params.set("vehicle_id", String(filters.vehicleId));
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||||
<title>Kniha jízd - ${periodName}</title>
|
const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
|
||||||
<style>
|
`${API_BASE}/trips/print?${params.toString()}`,
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
);
|
||||||
body {
|
printTrips = data.trips.map(mapTrip);
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
printTotals = data.totals;
|
||||||
font-size: 10px;
|
} catch (e) {
|
||||||
line-height: 1.4;
|
printWindow.close();
|
||||||
color: #000;
|
console.error("Trip print data fetch failed:", e);
|
||||||
background: #fff;
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
padding: 10mm;
|
return;
|
||||||
}
|
}
|
||||||
.print-header {
|
|
||||||
display: flex;
|
// The user closed the popup while the data was loading — a deliberate
|
||||||
justify-content: space-between;
|
// cancel, not an error.
|
||||||
align-items: flex-start;
|
if (printWindow.closed) return;
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 10px;
|
// The document is built directly from the fetched data (no hidden-div /
|
||||||
border-bottom: 2px solid #333;
|
// setTimeout round-trip), so it can be neither unmounted nor stale.
|
||||||
}
|
printWindow.document.write(
|
||||||
.print-header-left { display: flex; align-items: center; gap: 12px; }
|
buildPrintHtml({
|
||||||
.print-logo { height: 40px; width: auto; }
|
trips: printTrips,
|
||||||
.print-header-text { text-align: left; }
|
totals: printTotals,
|
||||||
.print-header-right { text-align: right; }
|
periodName: getPeriodName(),
|
||||||
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
|
companyName,
|
||||||
.print-header .company { font-size: 11px; color: #666; }
|
vehicleName: getSelectedVehicleName(),
|
||||||
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
userName: getSelectedUserName(),
|
||||||
.print-header .filters { font-size: 10px; color: #666; }
|
logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
|
||||||
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
|
}),
|
||||||
.summary {
|
);
|
||||||
display: flex;
|
printWindow.document.close();
|
||||||
justify-content: space-around;
|
printWindow.onload = () => {
|
||||||
margin-bottom: 15px;
|
printWindow.print();
|
||||||
padding: 10px;
|
};
|
||||||
background: #f5f5f5;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
.summary-item { text-align: center; }
|
|
||||||
.summary-value { font-size: 14px; font-weight: 700; }
|
|
||||||
.summary-label { font-size: 9px; color: #666; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
|
||||||
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
|
|
||||||
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
|
|
||||||
td { font-size: 9px; }
|
|
||||||
tr:nth-child(even) { background: #f9f9f9; }
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-right { text-align: right; }
|
|
||||||
tfoot td { background: #eee; font-weight: 600; }
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
|
||||||
.badge-warning { background: #fef3c7; color: #d97706; }
|
|
||||||
@media print {
|
|
||||||
body { padding: 5mm; }
|
|
||||||
@page { size: A4 landscape; margin: 5mm; }
|
|
||||||
thead { display: table-header-group; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${content}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
printWindow.document.close();
|
|
||||||
printWindow.onload = () => {
|
|
||||||
printWindow.print();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateDistance = (): number => {
|
const calculateDistance = (): number => {
|
||||||
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const totals = {
|
const totals = {
|
||||||
count: trips.length,
|
count: stats?.count ?? 0,
|
||||||
total: trips.reduce((sum, t) => sum + t.distance, 0),
|
total: stats?.total_km ?? 0,
|
||||||
business: trips
|
business: stats?.business_km ?? 0,
|
||||||
.filter((t) => Number(t.is_business))
|
|
||||||
.reduce((sum, t) => sum + t.distance, 0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: DataColumn<Trip>[] = [
|
const columns: DataColumn<Trip>[] = [
|
||||||
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
|
|||||||
>
|
>
|
||||||
<Typography variant="h4">Správa knihy jízd</Typography>
|
<Typography variant="h4">Správa knihy jízd</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
{trips.length > 0 && (
|
{(pagination?.total ?? 0) > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<Box sx={{ flex: "0 0 180px" }}>
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
<MonthField
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(val) => {
|
||||||
|
setFilterPeriod(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: "0 0 220px" }}>
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
<Select
|
<Select
|
||||||
value={filterVehicleId}
|
value={filterVehicleId}
|
||||||
onChange={setFilterVehicleId}
|
onChange={(value) => {
|
||||||
|
setFilterVehicleId(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "Všechna vozidla" },
|
{ value: "", label: "Všechna vozidla" },
|
||||||
...vehicles.map((v) => ({
|
...vehicles.map((v) => ({
|
||||||
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
|
|||||||
<Box sx={{ flex: "0 0 220px" }}>
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
<Select
|
<Select
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={setFilterUserId}
|
onChange={(value) => {
|
||||||
|
setFilterUserId(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "Všichni řidiči" },
|
{ value: "", label: "Všichni řidiči" },
|
||||||
...tripUsers.map((u) => ({
|
...tripUsers.map((u) => ({
|
||||||
@@ -632,14 +811,21 @@ export default function TripsAdmin() {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : (
|
) : (
|
||||||
<DataTable<Trip>
|
<>
|
||||||
columns={columns}
|
<DataTable<Trip>
|
||||||
rows={trips}
|
columns={columns}
|
||||||
rowKey={(trip) => trip.id}
|
rows={trips}
|
||||||
empty={
|
rowKey={(trip) => trip.id}
|
||||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
empty={
|
||||||
}
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
|
|||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hidden Print Content */}
|
|
||||||
{trips.length > 0 && (
|
|
||||||
<div ref={printRef} style={{ display: "none" }}>
|
|
||||||
<div className="print-header">
|
|
||||||
<div className="print-header-left">
|
|
||||||
<img
|
|
||||||
src="/api/admin/company-settings/logo?variant=light"
|
|
||||||
alt=""
|
|
||||||
className="print-logo"
|
|
||||||
/>
|
|
||||||
<div className="print-header-text">
|
|
||||||
<h1>KNIHA JÍZD</h1>
|
|
||||||
<div className="company">{companyName}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="print-header-right">
|
|
||||||
<div className="period">{getPeriodName()}</div>
|
|
||||||
{getSelectedVehicleName() && (
|
|
||||||
<div className="filters">
|
|
||||||
Vozidlo: {getSelectedVehicleName()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{getSelectedUserName() && (
|
|
||||||
<div className="filters">Řidič: {getSelectedUserName()}</div>
|
|
||||||
)}
|
|
||||||
<div className="generated">
|
|
||||||
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="summary">
|
|
||||||
<div className="summary-item">
|
|
||||||
<div className="summary-value">{totals.count}</div>
|
|
||||||
<div className="summary-label">Počet jízd</div>
|
|
||||||
</div>
|
|
||||||
<div className="summary-item">
|
|
||||||
<div className="summary-value">{formatKm(totals.total)} km</div>
|
|
||||||
<div className="summary-label">Celkem</div>
|
|
||||||
</div>
|
|
||||||
<div className="summary-item">
|
|
||||||
<div className="summary-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</div>
|
|
||||||
<div className="summary-label">Služební</div>
|
|
||||||
</div>
|
|
||||||
<div className="summary-item">
|
|
||||||
<div className="summary-value">
|
|
||||||
{formatKm(totals.total - totals.business)} km
|
|
||||||
</div>
|
|
||||||
<div className="summary-label">Soukromé</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: "70px" }}>Datum</th>
|
|
||||||
<th style={{ width: "80px" }}>Řidič</th>
|
|
||||||
<th style={{ width: "70px" }}>Vozidlo</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th style={{ width: "70px" }} className="text-right">
|
|
||||||
Stav km
|
|
||||||
</th>
|
|
||||||
<th style={{ width: "60px" }} className="text-right">
|
|
||||||
Vzdálenost
|
|
||||||
</th>
|
|
||||||
<th style={{ width: "55px" }} className="text-center">
|
|
||||||
Typ
|
|
||||||
</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td>{formatDate(trip.trip_date)}</td>
|
|
||||||
<td>{trip.driver_name}</td>
|
|
||||||
<td>{trip.spz}</td>
|
|
||||||
<td>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<strong>{formatKm(trip.distance)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td className="text-center">
|
|
||||||
<span
|
|
||||||
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{trip.notes || ""}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-right">
|
|
||||||
Celkem:
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<strong>{formatKm(totals.total)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td colSpan={2}></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageEnter>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
|
import {
|
||||||
|
tripHistoryOptions,
|
||||||
|
tripStatsOptions,
|
||||||
|
tripVehiclesOptions,
|
||||||
|
type BackendTrip,
|
||||||
|
} from "../lib/queries/trips";
|
||||||
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
DataTable,
|
DataTable,
|
||||||
Select,
|
Select,
|
||||||
MonthField,
|
MonthField,
|
||||||
|
Pagination,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
@@ -91,18 +98,35 @@ export default function TripsHistory() {
|
|||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
});
|
});
|
||||||
const [vehicleId, setVehicleId] = useState("");
|
const [vehicleId, setVehicleId] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
||||||
const vehicles = vehiclesData;
|
const vehicles = vehiclesData;
|
||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
const {
|
||||||
|
items: tripsData,
|
||||||
|
pagination,
|
||||||
|
isPending,
|
||||||
|
} = usePaginatedQuery<BackendTrip>(
|
||||||
tripHistoryOptions({
|
tripHistoryOptions({
|
||||||
month,
|
month,
|
||||||
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
|
page,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stat cards: month totals over the WHOLE filtered set (server-side
|
||||||
|
// aggregate over the same filters), not just the visible page.
|
||||||
|
const { data: stats } = useQuery(
|
||||||
|
tripStatsOptions({
|
||||||
|
month,
|
||||||
|
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||||
|
userId: user?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const trips: Trip[] = (tripsData ?? []).map((t) => ({
|
|
||||||
|
const trips: Trip[] = tripsData.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
trip_date: t.trip_date,
|
trip_date: t.trip_date,
|
||||||
spz: t.vehicles?.spz ?? "",
|
spz: t.vehicles?.spz ?? "",
|
||||||
@@ -118,14 +142,11 @@ export default function TripsHistory() {
|
|||||||
notes: t.notes ?? undefined,
|
notes: t.notes ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totals = trips.reduce(
|
const totals = {
|
||||||
(acc, t) => ({
|
count: stats?.count ?? 0,
|
||||||
total: acc.total + (t.distance || 0),
|
total: stats?.total_km ?? 0,
|
||||||
business: acc.business + (t.is_business ? t.distance || 0 : 0),
|
business: stats?.business_km ?? 0,
|
||||||
count: acc.count + 1,
|
};
|
||||||
}),
|
|
||||||
{ total: 0, business: 0, count: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasPermission("trips.history")) return <Forbidden />;
|
if (!hasPermission("trips.history")) return <Forbidden />;
|
||||||
|
|
||||||
@@ -221,12 +242,21 @@ export default function TripsHistory() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<Box sx={{ flex: "0 0 180px" }}>
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
<MonthField
|
||||||
|
value={month}
|
||||||
|
onChange={(val) => {
|
||||||
|
setMonth(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: "0 0 220px" }}>
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
<Select
|
<Select
|
||||||
value={vehicleId}
|
value={vehicleId}
|
||||||
onChange={(value) => setVehicleId(value)}
|
onChange={(value) => {
|
||||||
|
setVehicleId(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "Všechna vozidla" },
|
{ value: "", label: "Všechna vozidla" },
|
||||||
...vehicles.map((v) => ({
|
...vehicles.map((v) => ({
|
||||||
@@ -275,14 +305,21 @@ export default function TripsHistory() {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : (
|
) : (
|
||||||
<DataTable<Trip>
|
<>
|
||||||
columns={columns}
|
<DataTable<Trip>
|
||||||
rows={trips}
|
columns={columns}
|
||||||
rowKey={(trip) => trip.id}
|
rows={trips}
|
||||||
empty={
|
rowKey={(trip) => trip.id}
|
||||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
empty={
|
||||||
}
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</PageEnter>
|
</PageEnter>
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem {
|
|||||||
|
|
||||||
const API_BASE = "/api/admin/warehouse/items";
|
const API_BASE = "/api/admin/warehouse/items";
|
||||||
|
|
||||||
|
// Must match UnitEnum in src/schemas/warehouse.schema.ts — the server rejects
|
||||||
|
// any other value (CreateItemSchema/UpdateItemSchema), so free text here would
|
||||||
|
// make the save 400.
|
||||||
|
const UNIT_OPTIONS = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const;
|
||||||
|
|
||||||
interface ItemForm {
|
interface ItemForm {
|
||||||
item_number: string;
|
item_number: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -449,15 +454,14 @@ export default function WarehouseItemDetail() {
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Jednotka" required error={errors.unit}>
|
<Field label="Jednotka" required error={errors.unit}>
|
||||||
<TextField
|
<Select
|
||||||
value={form.unit}
|
value={form.unit}
|
||||||
error={!!errors.unit}
|
error={!!errors.unit}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
updateForm("unit", e.target.value);
|
updateForm("unit", v);
|
||||||
setErrors((prev) => ({ ...prev, unit: "" }));
|
setErrors((prev) => ({ ...prev, unit: "" }));
|
||||||
}}
|
}}
|
||||||
placeholder="ks, m, kg..."
|
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
|
||||||
fullWidth
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Link from "@mui/material/Link";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
|
||||||
|
import { apiFetch } from "../utils/api";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
warehouseReceiptDetailOptions,
|
warehouseReceiptDetailOptions,
|
||||||
@@ -161,6 +163,33 @@ export default function WarehouseReceiptDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A plain <a href> cannot send the Authorization header, so attachments are
|
||||||
|
// fetched via apiFetch and handed to the browser as a temporary blob URL.
|
||||||
|
const handleDownloadAttachment = async (att: WarehouseReceiptAttachment) => {
|
||||||
|
if (!receipt) return;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/admin/warehouse/receipts/${receipt.id}/attachments/${att.id}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
alert.error(result.error || "Nepodařilo se stáhnout přílohu");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = att.file_name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const confirming = confirmMutation.isPending;
|
const confirming = confirmMutation.isPending;
|
||||||
const cancelling = cancelMutation.isPending;
|
const cancelling = cancelMutation.isPending;
|
||||||
|
|
||||||
@@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() {
|
|||||||
width: "45%",
|
width: "45%",
|
||||||
bold: true,
|
bold: true,
|
||||||
render: (att) => (
|
render: (att) => (
|
||||||
<a
|
<Link
|
||||||
href={`/api/admin/warehouse/receipts/${r.id}/attachments/${att.id}`}
|
component="button"
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={() => handleDownloadAttachment(att)}
|
||||||
|
sx={{ textAlign: "left", font: "inherit" }}
|
||||||
>
|
>
|
||||||
{att.file_name}
|
{att.file_name}
|
||||||
</a>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
|
|||||||
url: () =>
|
url: () =>
|
||||||
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
||||||
method: () => (editingSupplier ? "PUT" : "POST"),
|
method: () => (editingSupplier ? "PUT" : "POST"),
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
alert.success(data?.message || "Dodavatel byl uložen");
|
alert.success(data?.message || "Dodavatel byl uložen");
|
||||||
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
|
|||||||
const deleteMutation = useApiMutation<number, { message?: string }>({
|
const deleteMutation = useApiMutation<number, { message?: string }>({
|
||||||
url: (id) => `${API_BASE}/${id}`,
|
url: (id) => `${API_BASE}/${id}`,
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setDeactivateConfirm({ show: false, supplier: null });
|
setDeactivateConfirm({ show: false, supplier: null });
|
||||||
alert.success(data?.message || "Dodavatel byl smazán");
|
alert.success(data?.message || "Dodavatel byl smazán");
|
||||||
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/${id}`,
|
url: ({ id }) => `${API_BASE}/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
||||||
|
|||||||
88
src/admin/ui/SupplierPicker.tsx
Normal file
88
src/admin/ui/SupplierPicker.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
|
||||||
|
import MuiTextField from "@mui/material/TextField";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import type { Supplier } from "../lib/queries/issued-orders";
|
||||||
|
|
||||||
|
interface SupplierPickerProps {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
/** Selected supplier id (FK), or null when none chosen. */
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When set, draws the input in its error state (red border). The error TEXT
|
||||||
|
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
|
||||||
|
* render helperText here to avoid duplicating the message.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Optional id forwarded by <Field> for label association. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches the supplier name AND the IČO, so typing either finds the
|
||||||
|
// record. Match on a stringified "name + ico" so MUI's default
|
||||||
|
// case-insensitive substring filtering covers both.
|
||||||
|
const filterSuppliers = createFilterOptions<Supplier>({
|
||||||
|
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable supplier picker for issued orders (purchase orders) — a
|
||||||
|
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
|
||||||
|
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
|
||||||
|
* renders bare (no label/error text); wrap it in the page's
|
||||||
|
* <Field label error> for the label + error line.
|
||||||
|
*/
|
||||||
|
export default function SupplierPicker({
|
||||||
|
suppliers,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
}: SupplierPickerProps) {
|
||||||
|
const selected = suppliers.find((s) => s.id === value) ?? null;
|
||||||
|
return (
|
||||||
|
<Autocomplete<Supplier>
|
||||||
|
options={suppliers}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, opt) => onChange(opt ? opt.id : null)}
|
||||||
|
getOptionLabel={(s) => s?.name ?? ""}
|
||||||
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||||
|
filterOptions={filterSuppliers}
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoHighlight
|
||||||
|
renderOption={(props, s) => {
|
||||||
|
const { key, ...rest } = props as typeof props & { key: string };
|
||||||
|
return (
|
||||||
|
<Box component="li" key={key} {...rest}>
|
||||||
|
<Box>
|
||||||
|
{s.name}
|
||||||
|
{s.ico && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: "block", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
IČ: {s.ico}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<MuiTextField
|
||||||
|
{...params}
|
||||||
|
id={id}
|
||||||
|
placeholder={placeholder ?? "Vyberte dodavatele…"}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
|
|||||||
export { default as Select } from "./Select";
|
export { default as Select } from "./Select";
|
||||||
export type { SelectOption } from "./Select";
|
export type { SelectOption } from "./Select";
|
||||||
export { default as CustomerPicker } from "./CustomerPicker";
|
export { default as CustomerPicker } from "./CustomerPicker";
|
||||||
|
export { default as SupplierPicker } from "./SupplierPicker";
|
||||||
export { default as StatusChip } from "./StatusChip";
|
export { default as StatusChip } from "./StatusChip";
|
||||||
export { CheckboxField } from "./Checkbox";
|
export { CheckboxField } from "./Checkbox";
|
||||||
export { default as Alert } from "./Alert";
|
export { default as Alert } from "./Alert";
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => {
|
|||||||
return extractTime(datetime);
|
return extractTime(datetime);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the
|
||||||
|
* combined local-datetime wire format the attendance API expects
|
||||||
|
* ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString`
|
||||||
|
* and parsed with `new Date(...)` as local time). Returns null when either
|
||||||
|
* part is unset — "no value" for the optional datetime fields.
|
||||||
|
*/
|
||||||
|
export const combineDatetime = (date: string, time: string): string | null =>
|
||||||
|
date && time ? `${date}T${time}:00` : null;
|
||||||
|
|
||||||
export const calcProjectMinutesTotal = (
|
export const calcProjectMinutesTotal = (
|
||||||
logs: Array<{
|
logs: Array<{
|
||||||
project_id?: number;
|
project_id?: number;
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export function todayLocalStr(): string {
|
|||||||
return `${d.getFullYear()}-${m}-${day}`;
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a server-returned numeric (number | decimal-string | null/undefined)
|
||||||
|
* to a number, falling back ONLY when the value is missing or not numeric —
|
||||||
|
* NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The
|
||||||
|
* `Number(x) || fallback` idiom silently turns stored zeros into the fallback.
|
||||||
|
*/
|
||||||
|
export function numberOr(raw: unknown, fallback: number): number {
|
||||||
|
if (raw == null || raw === "") return fallback;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatKm(km: number | string): string {
|
export function formatKm(km: number | string): string {
|
||||||
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
|
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,14 @@ export default async function attendanceRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Default: paginated records list ---
|
// --- Default: paginated records list ---
|
||||||
const { page, limit, skip, order } = parsePagination(query);
|
// maxLimit must allow a COMPLETE month: the admin month view fetches all
|
||||||
|
// records at once and computes the KPI cards / summary totals client-side
|
||||||
|
// (useAttendanceAdmin). With the default 100-row cap, months with more
|
||||||
|
// than 100 records were silently truncated (newest-first), so the screen
|
||||||
|
// totals dropped the earliest days while the print stayed complete.
|
||||||
|
const { page, limit, skip, order } = parsePagination(query, {
|
||||||
|
maxLimit: 2000,
|
||||||
|
});
|
||||||
const isAdmin = authData.permissions.includes("attendance.manage");
|
const isAdmin = authData.permissions.includes("attendance.manage");
|
||||||
const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
|
const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
|
||||||
const userId =
|
const userId =
|
||||||
@@ -418,6 +425,8 @@ export default async function attendanceRoutes(
|
|||||||
departure_lng: body.departure_lng,
|
departure_lng: body.departure_lng,
|
||||||
departure_accuracy: body.departure_accuracy,
|
departure_accuracy: body.departure_accuracy,
|
||||||
departure_address: body.departure_address,
|
departure_address: body.departure_address,
|
||||||
|
break_start: body.break_start,
|
||||||
|
break_end: body.break_end,
|
||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
project_id: body.project_id,
|
project_id: body.project_id,
|
||||||
leave_type: body.leave_type,
|
leave_type: body.leave_type,
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
|
||||||
|
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
|
||||||
|
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
|
||||||
|
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
|
||||||
|
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
|
||||||
const todayStart = new Date(
|
const todayStart = new Date(
|
||||||
now.getFullYear(),
|
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||||
now.getMonth(),
|
|
||||||
now.getDate(),
|
|
||||||
);
|
);
|
||||||
const todayEnd = new Date(
|
const todayEnd = new Date(
|
||||||
now.getFullYear(),
|
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||||
now.getMonth(),
|
|
||||||
now.getDate() + 1,
|
|
||||||
);
|
);
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
|||||||
@@ -156,6 +156,31 @@ function buildAddressLines(
|
|||||||
return { name, lines };
|
return { name, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Address block for the sklad_suppliers counterparty. Unlike customers (which
|
||||||
|
* have structured street/city/postal columns), suppliers.address is a single
|
||||||
|
* Text blob — split it on newlines into one rendered line each (a blob without
|
||||||
|
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
|
||||||
|
* columns, prefixed with the same translated labels the customer block used.
|
||||||
|
*/
|
||||||
|
function buildSupplierLines(
|
||||||
|
supplier: Record<string, unknown> | null,
|
||||||
|
tObj: Record<string, string>,
|
||||||
|
): AddressResult {
|
||||||
|
if (!supplier) return { name: "", lines: [] };
|
||||||
|
const name = String(supplier.name || "");
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (supplier.address) {
|
||||||
|
for (const part of String(supplier.address).split(/\r?\n/)) {
|
||||||
|
const line = part.trim();
|
||||||
|
if (line) lines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
|
||||||
|
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
|
||||||
|
return { name, lines };
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Translations ────────────────────────────────────────────────── */
|
/* ── Translations ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
type Lang = "cs" | "en";
|
type Lang = "cs" | "en";
|
||||||
@@ -165,7 +190,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
title: "OBJEDNÁVKA",
|
title: "OBJEDNÁVKA",
|
||||||
heading: "OBJEDNÁVKA č.",
|
heading: "OBJEDNÁVKA č.",
|
||||||
// PO direction: WE (company) are the buyer (Odběratel), the
|
// PO direction: WE (company) are the buyer (Odběratel), the
|
||||||
// selected customer record is the supplier (Dodavatel).
|
// selected sklad_suppliers record is the supplier (Dodavatel).
|
||||||
supplier: "Dodavatel",
|
supplier: "Dodavatel",
|
||||||
buyer: "Odběratel",
|
buyer: "Odběratel",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
@@ -244,7 +269,7 @@ interface IssuedOrderPdfItem {
|
|||||||
export function renderIssuedOrderHtml(
|
export function renderIssuedOrderHtml(
|
||||||
order: IssuedOrderPdfData,
|
order: IssuedOrderPdfData,
|
||||||
items: IssuedOrderPdfItem[],
|
items: IssuedOrderPdfItem[],
|
||||||
customer: Record<string, unknown> | null,
|
supplier: Record<string, unknown> | null,
|
||||||
settings: Record<string, unknown> | null,
|
settings: Record<string, unknown> | null,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
issuer: { name: string },
|
issuer: { name: string },
|
||||||
@@ -268,14 +293,14 @@ export function renderIssuedOrderHtml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PO direction: our company (settings) = Odběratel (buyer);
|
// PO direction: our company (settings) = Odběratel (buyer);
|
||||||
// the customer record = Dodavatel (supplier).
|
// the sklad_suppliers record = Dodavatel (supplier).
|
||||||
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
||||||
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
|
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
||||||
|
|
||||||
const buyerLinesHtml = buyer.lines
|
const buyerLinesHtml = buyer.lines
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
const supplierLinesHtml = supplier.lines
|
const supplierLinesHtml = supplierAddr.lines
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -450,7 +475,7 @@ export function renderIssuedOrderHtml(
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
.header-grid td.addr-customer {
|
.header-grid td.addr-supplier {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
.header-grid td.details-bank {
|
.header-grid td.details-bank {
|
||||||
@@ -715,7 +740,7 @@ ${indentCSS}
|
|||||||
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
|
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
|
||||||
<table class="header-grid" cellspacing="0">
|
<table class="header-grid" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -723,9 +748,9 @@ ${indentCSS}
|
|||||||
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
||||||
${buyerLinesHtml}
|
${buyerLinesHtml}
|
||||||
</td>
|
</td>
|
||||||
<td class="addr-customer">
|
<td class="addr-supplier">
|
||||||
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||||||
<div class="address-name">${escapeHtml(supplier.name)}</div>
|
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
|
||||||
${supplierLinesHtml}
|
${supplierLinesHtml}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -818,9 +843,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
where: { issued_order_id: id },
|
where: { issued_order_id: id },
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
});
|
});
|
||||||
const customer = order.customer_id
|
const supplier = order.supplier_id
|
||||||
? ((await prisma.customers.findUnique({
|
? ((await prisma.sklad_suppliers.findUnique({
|
||||||
where: { id: order.customer_id },
|
where: { id: order.supplier_id },
|
||||||
})) as Record<string, unknown> | null)
|
})) as Record<string, unknown> | null)
|
||||||
: null;
|
: null;
|
||||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||||
@@ -838,7 +863,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
customer,
|
supplier,
|
||||||
settings,
|
settings,
|
||||||
lang,
|
lang,
|
||||||
issuer,
|
issuer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import prisma from "../../config/database";
|
||||||
|
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
@@ -34,7 +35,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
order,
|
order,
|
||||||
search,
|
search,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
@@ -66,7 +67,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
const result = await getIssuedOrderTotals({
|
const result = await getIssuedOrderTotals({
|
||||||
search: query.search ? String(query.search) : undefined,
|
search: query.search ? String(query.search) : undefined,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
@@ -74,6 +75,40 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
|
||||||
|
// the PO supplier picker. Guarded by the ORDERS permissions (any of
|
||||||
|
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
|
||||||
|
// gated by warehouse.manage, which orders users typically lack — without
|
||||||
|
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
|
||||||
|
// so the literal "suppliers" path isn't captured as an order id.
|
||||||
|
fastify.get(
|
||||||
|
"/suppliers",
|
||||||
|
{
|
||||||
|
preHandler: requireAnyPermission(
|
||||||
|
"orders.view",
|
||||||
|
"orders.create",
|
||||||
|
"orders.edit",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (_request, reply) => {
|
||||||
|
const suppliers = await prisma.sklad_suppliers.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ico: true,
|
||||||
|
dic: true,
|
||||||
|
address: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
// id tiebreak so same-name suppliers sort deterministically.
|
||||||
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return success(reply, suppliers);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/admin/issued-orders/:id
|
// GET /api/admin/issued-orders/:id
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -95,6 +130,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
const order = await createIssuedOrder(parsed.data);
|
const order = await createIssuedOrder(parsed.data);
|
||||||
|
if ("error" in order) {
|
||||||
|
if (order.error === "supplier_not_found")
|
||||||
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
|
return error(reply, "Neznámá chyba", 500);
|
||||||
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
@@ -125,6 +165,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
if (result.error === "not_found")
|
if (result.error === "not_found")
|
||||||
return error(reply, "Objednávka nenalezena", 404);
|
return error(reply, "Objednávka nenalezena", 404);
|
||||||
|
if (result.error === "supplier_not_found")
|
||||||
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
if (result.error === "invalid_transition")
|
if (result.error === "invalid_transition")
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ export default async function ordersPdfRoutes(
|
|||||||
|
|
||||||
const order = await prisma.orders.findUnique({
|
const order = await prisma.orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
// The confirmation PDF never renders the PO attachment — don't pull
|
||||||
|
// the blob just to read the order header/items.
|
||||||
|
omit: { attachment_data: true },
|
||||||
include: {
|
include: {
|
||||||
customers: true,
|
customers: true,
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order_items: { orderBy: { position: "asc" } },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
|
|||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { config } from "../../config/env";
|
import { config } from "../../config/env";
|
||||||
import {
|
import {
|
||||||
@@ -110,10 +111,12 @@ export default async function ordersRoutes(
|
|||||||
const attachment = await getOrderAttachment(id);
|
const attachment = await getOrderAttachment(id);
|
||||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||||
|
|
||||||
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
|
|
||||||
return reply
|
return reply
|
||||||
.type("application/pdf")
|
.type("application/pdf")
|
||||||
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
|
.header(
|
||||||
|
"Content-Disposition",
|
||||||
|
contentDisposition("inline", attachment.filename),
|
||||||
|
)
|
||||||
.send(attachment.data);
|
.send(attachment.data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import prisma from "../../config/database";
|
|||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from "../../schemas/received-invoices.schema";
|
} from "../../schemas/received-invoices.schema";
|
||||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
import { toCzk } from "../../services/exchange-rates";
|
import { toCzk } from "../../services/exchange-rates";
|
||||||
|
import { utcMidnightOfLocalDay } from "../../utils/date";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||||
@@ -294,10 +296,14 @@ export default async function receivedInvoicesRoutes(
|
|||||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||||
|
|
||||||
const mime = invoice.file_mime || "application/pdf";
|
const mime = invoice.file_mime || "application/pdf";
|
||||||
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
|
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
|
||||||
|
// window for viewing, not as a forced download.
|
||||||
return reply
|
return reply
|
||||||
.type(mime)
|
.type(mime)
|
||||||
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
|
.header(
|
||||||
|
"Content-Disposition",
|
||||||
|
contentDisposition("inline", invoice.file_name),
|
||||||
|
)
|
||||||
.send(nasFile.data);
|
.send(nasFile.data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -556,14 +562,17 @@ export default async function receivedInvoicesRoutes(
|
|||||||
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
||||||
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
||||||
|
|
||||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
// Auto-set paid_date when status transitions to paid (matching PHP).
|
||||||
|
// paid_date is @db.Date (truncated to the UTC date part) —
|
||||||
|
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
|
||||||
|
// 00:00–02:00 Prague window (a bare new Date() stored yesterday there).
|
||||||
const newStatus =
|
const newStatus =
|
||||||
body.status !== undefined
|
body.status !== undefined
|
||||||
? String(body.status)
|
? String(body.status)
|
||||||
: String(existing.status);
|
: String(existing.status);
|
||||||
const paidDate =
|
const paidDate =
|
||||||
newStatus === "paid" && String(existing.status) !== "paid"
|
newStatus === "paid" && String(existing.status) !== "paid"
|
||||||
? new Date()
|
? utcMidnightOfLocalDay()
|
||||||
: body.paid_date !== undefined
|
: body.paid_date !== undefined
|
||||||
? body.paid_date
|
? body.paid_date
|
||||||
? new Date(String(body.paid_date))
|
? new Date(String(body.paid_date))
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ export default async function totpRoutes(
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
const { login_token, backup_code: code } = parsed.data;
|
const { login_token, backup_code: code, remember_me } = parsed.data;
|
||||||
|
const rememberMe = remember_me ?? false;
|
||||||
|
|
||||||
const tokenHash = crypto
|
const tokenHash = crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
@@ -393,14 +394,18 @@ export default async function totpRoutes(
|
|||||||
.update(refreshTokenRaw)
|
.update(refreshTokenRaw)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|
||||||
|
// Mirror /login/totp: honor remember_me so a backup-code login keeps
|
||||||
|
// the same session longevity the user asked for at the password step.
|
||||||
|
const refreshExpiresIn = rememberMe
|
||||||
|
? config.jwt.refreshTokenRememberExpiry
|
||||||
|
: config.jwt.refreshTokenSessionExpiry;
|
||||||
|
|
||||||
await prisma.refresh_tokens.create({
|
await prisma.refresh_tokens.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: refreshTokenHash,
|
token_hash: refreshTokenHash,
|
||||||
expires_at: new Date(
|
expires_at: new Date(Date.now() + refreshExpiresIn * 1000),
|
||||||
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000,
|
remember_me: rememberMe,
|
||||||
),
|
|
||||||
remember_me: false,
|
|
||||||
ip_address: request.ip,
|
ip_address: request.ip,
|
||||||
user_agent: request.headers["user-agent"] ?? null,
|
user_agent: request.headers["user-agent"] ?? null,
|
||||||
},
|
},
|
||||||
@@ -411,7 +416,7 @@ export default async function totpRoutes(
|
|||||||
secure: config.isProduction,
|
secure: config.isProduction,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
path: "/api/admin",
|
path: "/api/admin",
|
||||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
maxAge: refreshExpiresIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -6,6 +6,99 @@ import { success, paginated, error, parseId } from "../../utils/response";
|
|||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
|
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
|
||||||
|
import { AuthData } from "../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared filter/scoping logic for the trip list AND the stats endpoint — the
|
||||||
|
* stats MUST honor exactly the same filters as the list so the cards always
|
||||||
|
* describe the rows the list is paging through.
|
||||||
|
*
|
||||||
|
* Scoping: admins / trips.manage holders see all trips (optionally filtered
|
||||||
|
* by user_id); everyone else is hard-scoped to their own trips.
|
||||||
|
*/
|
||||||
|
function buildTripsWhere(
|
||||||
|
query: Record<string, unknown>,
|
||||||
|
authData: AuthData,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
// Admin role bypasses permission checks entirely (requireAnyPermission
|
||||||
|
// lets it through with an empty permissions list), so treat the admin
|
||||||
|
// role as a manager for scoping purposes too.
|
||||||
|
const isManager =
|
||||||
|
authData.roleName === "admin" ||
|
||||||
|
authData.permissions.includes("trips.manage");
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (!isManager) where.user_id = authData.userId;
|
||||||
|
else if (query.user_id) {
|
||||||
|
// Non-numeric filter values are ignored (like an invalid month below) —
|
||||||
|
// NaN in a Prisma where clause would throw and surface as a 500.
|
||||||
|
const uid = Number(query.user_id);
|
||||||
|
if (Number.isFinite(uid)) where.user_id = uid;
|
||||||
|
}
|
||||||
|
if (query.vehicle_id) {
|
||||||
|
const vid = Number(query.vehicle_id);
|
||||||
|
if (Number.isFinite(vid)) where.vehicle_id = vid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
|
||||||
|
if (query.month) {
|
||||||
|
const monthStr = String(query.month);
|
||||||
|
let yr: number, mo: number;
|
||||||
|
if (monthStr.includes("-")) {
|
||||||
|
// Combined YYYY-MM format
|
||||||
|
const [yStr, mStr] = monthStr.split("-");
|
||||||
|
yr = Number(yStr);
|
||||||
|
mo = Number(mStr);
|
||||||
|
} else if (query.year) {
|
||||||
|
yr = Number(query.year);
|
||||||
|
mo = Number(query.month);
|
||||||
|
} else {
|
||||||
|
yr = NaN;
|
||||||
|
mo = NaN;
|
||||||
|
}
|
||||||
|
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
||||||
|
// Use explicit date strings to avoid toJSON timezone shift
|
||||||
|
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||||
|
const nextMonth =
|
||||||
|
mo === 12
|
||||||
|
? `${yr + 1}-01-01`
|
||||||
|
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||||
|
where.trip_date = {
|
||||||
|
gte: new Date(monthStart),
|
||||||
|
lt: new Date(nextMonth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute a vehicle's actual_km as MAX(end_km) over its trips, falling
|
||||||
|
* back to initial_km when the vehicle has no trips. Shared by the POST,
|
||||||
|
* PUT and DELETE handlers so the odometer logic can't drift.
|
||||||
|
*/
|
||||||
|
async function recomputeVehicleActualKm(vehicleId: number): Promise<void> {
|
||||||
|
const [maxTrip, vehicle] = await Promise.all([
|
||||||
|
prisma.trips.findFirst({
|
||||||
|
where: { vehicle_id: vehicleId },
|
||||||
|
orderBy: { end_km: "desc" },
|
||||||
|
select: { end_km: true },
|
||||||
|
}),
|
||||||
|
prisma.vehicles.findUnique({
|
||||||
|
where: { id: vehicleId },
|
||||||
|
select: { initial_km: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await prisma.vehicles.update({
|
||||||
|
where: { id: vehicleId },
|
||||||
|
data: {
|
||||||
|
actual_km: maxTrip
|
||||||
|
? Number(maxTrip.end_km)
|
||||||
|
: Number(vehicle?.initial_km ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function tripsRoutes(
|
export default async function tripsRoutes(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
@@ -22,55 +115,16 @@ export default async function tripsRoutes(
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const { page, limit, skip, order } = parsePagination(query);
|
const { page, limit, skip, order } = parsePagination(query);
|
||||||
const authData = request.authData!;
|
const where = buildTripsWhere(query, request.authData!);
|
||||||
// Admin role bypasses permission checks entirely (requireAnyPermission
|
|
||||||
// lets it through with an empty permissions list), so treat the admin
|
|
||||||
// role as a manager for scoping purposes too.
|
|
||||||
const isAdmin =
|
|
||||||
authData.roleName === "admin" ||
|
|
||||||
authData.permissions.includes("trips.manage");
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (!isAdmin) where.user_id = authData.userId;
|
|
||||||
else if (query.user_id) where.user_id = Number(query.user_id);
|
|
||||||
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
|
|
||||||
|
|
||||||
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
|
|
||||||
if (query.month) {
|
|
||||||
const monthStr = String(query.month);
|
|
||||||
let yr: number, mo: number;
|
|
||||||
if (monthStr.includes("-")) {
|
|
||||||
// Combined YYYY-MM format
|
|
||||||
const [yStr, mStr] = monthStr.split("-");
|
|
||||||
yr = Number(yStr);
|
|
||||||
mo = Number(mStr);
|
|
||||||
} else if (query.year) {
|
|
||||||
yr = Number(query.year);
|
|
||||||
mo = Number(query.month);
|
|
||||||
} else {
|
|
||||||
yr = NaN;
|
|
||||||
mo = NaN;
|
|
||||||
}
|
|
||||||
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
|
||||||
// Use explicit date strings to avoid toJSON timezone shift
|
|
||||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
|
||||||
const nextMonth =
|
|
||||||
mo === 12
|
|
||||||
? `${yr + 1}-01-01`
|
|
||||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
|
||||||
where.trip_date = {
|
|
||||||
gte: new Date(monthStart),
|
|
||||||
lt: new Date(nextMonth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [trips, total] = await Promise.all([
|
const [trips, total] = await Promise.all([
|
||||||
prisma.trips.findMany({
|
prisma.trips.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { trip_date: order },
|
// trip_date is date-only — same-day rows need the id tiebreak for
|
||||||
|
// deterministic pagination.
|
||||||
|
orderBy: [{ trip_date: order }, { id: order }],
|
||||||
include: {
|
include: {
|
||||||
users: { select: { id: true, first_name: true, last_name: true } },
|
users: { select: { id: true, first_name: true, last_name: true } },
|
||||||
vehicles: { select: { id: true, name: true, spz: true } },
|
vehicles: { select: { id: true, name: true, spz: true } },
|
||||||
@@ -83,6 +137,60 @@ export default async function tripsRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/admin/trips/stats — count/km totals over the WHOLE filtered set
|
||||||
|
// (not one page). Honors exactly the same filters + user scoping as the
|
||||||
|
// list (month/year, vehicle_id, user_id for managers) so the stat cards
|
||||||
|
// can't drift from the rows the list is paging through.
|
||||||
|
fastify.get(
|
||||||
|
"/stats",
|
||||||
|
{
|
||||||
|
preHandler: requireAnyPermission(
|
||||||
|
"trips.manage",
|
||||||
|
"trips.record",
|
||||||
|
"trips.history",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = request.query as Record<string, unknown>;
|
||||||
|
const where = buildTripsWhere(query, request.authData!);
|
||||||
|
|
||||||
|
// Legacy PHP-era rows can carry a stored `distance` that differs from
|
||||||
|
// end_km - start_km, and every row renderer prefers it
|
||||||
|
// (`distance ?? end_km - start_km`) — the totals must apply the same
|
||||||
|
// coalesce or the stat cards would disagree with the visible rows.
|
||||||
|
// That rules out a plain groupBy aggregate.
|
||||||
|
const rows = await prisma.trips.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
distance: true,
|
||||||
|
start_km: true,
|
||||||
|
end_km: true,
|
||||||
|
is_business: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let businessKm = 0;
|
||||||
|
let privateKm = 0;
|
||||||
|
for (const t of rows) {
|
||||||
|
const km =
|
||||||
|
t.distance != null
|
||||||
|
? Number(t.distance)
|
||||||
|
: Number(t.end_km) - Number(t.start_km);
|
||||||
|
count += 1;
|
||||||
|
if (t.is_business) businessKm += km;
|
||||||
|
else privateKm += km;
|
||||||
|
}
|
||||||
|
|
||||||
|
return success(reply, {
|
||||||
|
count,
|
||||||
|
total_km: businessKm + privateKm,
|
||||||
|
business_km: businessKm,
|
||||||
|
private_km: privateKm,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/admin/trips/users — users with trips.record permission
|
// GET /api/admin/trips/users — users with trips.record permission
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/users",
|
"/users",
|
||||||
@@ -121,28 +229,11 @@ export default async function tripsRoutes(
|
|||||||
{ preHandler: requirePermission("trips.manage") },
|
{ preHandler: requirePermission("trips.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
// Same shared filter source as the list + stats endpoints (incl. the
|
||||||
const filterVehicleId = query.vehicle_id
|
// month 1-12 validation). /print is manager-only (trips.manage guard),
|
||||||
? Number(query.vehicle_id)
|
// so buildTripsWhere always takes its manager branch here — user_id /
|
||||||
: null;
|
// vehicle_id / month filters behave exactly as before.
|
||||||
|
const where = buildTripsWhere(query, request.authData!);
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filterUserId) where.user_id = filterUserId;
|
|
||||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
|
||||||
if (query.month && query.year) {
|
|
||||||
// Use explicit date strings to avoid toJSON timezone shift
|
|
||||||
const yr = Number(query.year);
|
|
||||||
const mo = Number(query.month);
|
|
||||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
|
||||||
const nextMonth =
|
|
||||||
mo === 12
|
|
||||||
? `${yr + 1}-01-01`
|
|
||||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
|
||||||
where.trip_date = {
|
|
||||||
gte: new Date(monthStart),
|
|
||||||
lt: new Date(nextMonth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const trips = await prisma.trips.findMany({
|
const trips = await prisma.trips.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -150,7 +241,8 @@ export default async function tripsRoutes(
|
|||||||
users: { select: { id: true, first_name: true, last_name: true } },
|
users: { select: { id: true, first_name: true, last_name: true } },
|
||||||
vehicles: { select: { id: true, name: true, spz: true } },
|
vehicles: { select: { id: true, name: true, spz: true } },
|
||||||
},
|
},
|
||||||
orderBy: { trip_date: "asc" },
|
// trip_date is date-only — id tiebreak keeps same-day rows stable
|
||||||
|
orderBy: [{ trip_date: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const vehicles = await prisma.vehicles.findMany({
|
const vehicles = await prisma.vehicles.findMany({
|
||||||
@@ -166,7 +258,13 @@ export default async function tripsRoutes(
|
|||||||
let businessKm = 0;
|
let businessKm = 0;
|
||||||
let privateKm = 0;
|
let privateKm = 0;
|
||||||
for (const t of trips) {
|
for (const t of trips) {
|
||||||
const dist = Number(t.end_km) - Number(t.start_km);
|
// Same coalesce as the row renderers and /stats: legacy rows may
|
||||||
|
// store a `distance` differing from end_km - start_km, and the print
|
||||||
|
// footer must match the sum of the printed rows.
|
||||||
|
const dist =
|
||||||
|
t.distance != null
|
||||||
|
? Number(t.distance)
|
||||||
|
: Number(t.end_km) - Number(t.start_km);
|
||||||
totalKm += dist;
|
totalKm += dist;
|
||||||
if (t.is_business) businessKm += dist;
|
if (t.is_business) businessKm += dist;
|
||||||
else privateKm += dist;
|
else privateKm += dist;
|
||||||
@@ -251,21 +349,13 @@ export default async function tripsRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recompute actual_km from MAX(end_km) across all trips (same as
|
// Recompute actual_km from MAX(end_km) across all trips (same helper
|
||||||
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
|
// as PUT/DELETE). Setting it unconditionally to this trip's end_km
|
||||||
// regress the odometer when a back-dated trip with a lower end_km is
|
// would regress the odometer when a back-dated trip with a lower
|
||||||
// entered after a later trip with a higher reading.
|
// end_km is entered after a later trip with a higher reading. (The
|
||||||
const maxTrip = await prisma.trips.findFirst({
|
// helper's initial_km fallback is unreachable here — the just-created
|
||||||
where: { vehicle_id: vehicleId },
|
// trip guarantees at least one row.)
|
||||||
orderBy: { end_km: "desc" },
|
await recomputeVehicleActualKm(vehicleId);
|
||||||
select: { end_km: true },
|
|
||||||
});
|
|
||||||
if (maxTrip) {
|
|
||||||
await prisma.vehicles.update({
|
|
||||||
where: { id: vehicleId },
|
|
||||||
data: { actual_km: Number(maxTrip.end_km) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
@@ -312,6 +402,19 @@ export default async function tripsRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.vehicle_id !== undefined) {
|
||||||
|
const targetVehicleId = Number(body.vehicle_id);
|
||||||
|
if (targetVehicleId !== existing.vehicle_id) {
|
||||||
|
// Schema only guarantees a positive integer — without this check a
|
||||||
|
// nonexistent id would hit the FK as P2003 and surface as a 500.
|
||||||
|
const vehicleExists = await prisma.vehicles.findUnique({
|
||||||
|
where: { id: targetVehicleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!vehicleExists) return error(reply, "Vozidlo nenalezeno", 400);
|
||||||
|
}
|
||||||
|
data.vehicle_id = targetVehicleId;
|
||||||
|
}
|
||||||
if (body.trip_date !== undefined)
|
if (body.trip_date !== undefined)
|
||||||
data.trip_date = new Date(String(body.trip_date));
|
data.trip_date = new Date(String(body.trip_date));
|
||||||
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
||||||
@@ -325,20 +428,19 @@ export default async function tripsRoutes(
|
|||||||
|
|
||||||
await prisma.trips.update({ where: { id }, data });
|
await prisma.trips.update({ where: { id }, data });
|
||||||
|
|
||||||
// Update vehicle actual_km if end_km changed
|
// Recompute odometers when the km reading or the vehicle changed: the
|
||||||
if (body.end_km !== undefined) {
|
// trip's (possibly new) vehicle, and — when the trip moved between
|
||||||
const vehicleId = existing.vehicle_id;
|
// vehicles — the old one too, which may have lost its MAX(end_km) row.
|
||||||
const maxTrip = await prisma.trips.findFirst({
|
const newVehicleId =
|
||||||
where: { vehicle_id: vehicleId },
|
body.vehicle_id !== undefined
|
||||||
orderBy: { end_km: "desc" },
|
? Number(body.vehicle_id)
|
||||||
select: { end_km: true },
|
: existing.vehicle_id;
|
||||||
});
|
const vehicleChanged = newVehicleId !== existing.vehicle_id;
|
||||||
if (maxTrip) {
|
if (body.end_km !== undefined || vehicleChanged) {
|
||||||
await prisma.vehicles.update({
|
await recomputeVehicleActualKm(newVehicleId);
|
||||||
where: { id: vehicleId },
|
}
|
||||||
data: { actual_km: Number(maxTrip.end_km) },
|
if (vehicleChanged) {
|
||||||
});
|
await recomputeVehicleActualKm(existing.vehicle_id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -348,6 +450,8 @@ export default async function tripsRoutes(
|
|||||||
entityType: "trip",
|
entityType: "trip",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upravena jízda`,
|
description: `Upravena jízda`,
|
||||||
|
oldValues: existing,
|
||||||
|
newValues: data,
|
||||||
});
|
});
|
||||||
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
||||||
},
|
},
|
||||||
@@ -372,24 +476,9 @@ export default async function tripsRoutes(
|
|||||||
const vehicleId = existing.vehicle_id;
|
const vehicleId = existing.vehicle_id;
|
||||||
await prisma.trips.delete({ where: { id } });
|
await prisma.trips.delete({ where: { id } });
|
||||||
|
|
||||||
// Recalculate vehicle actual_km after deletion
|
// Recalculate vehicle actual_km after deletion (falls back to
|
||||||
const maxTrip = await prisma.trips.findFirst({
|
// initial_km when this was the vehicle's last trip).
|
||||||
where: { vehicle_id: vehicleId },
|
await recomputeVehicleActualKm(vehicleId);
|
||||||
orderBy: { end_km: "desc" },
|
|
||||||
select: { end_km: true },
|
|
||||||
});
|
|
||||||
const vehicle = await prisma.vehicles.findUnique({
|
|
||||||
where: { id: vehicleId },
|
|
||||||
select: { initial_km: true },
|
|
||||||
});
|
|
||||||
await prisma.vehicles.update({
|
|
||||||
where: { id: vehicleId },
|
|
||||||
data: {
|
|
||||||
actual_km: maxTrip
|
|
||||||
? Number(maxTrip.end_km)
|
|
||||||
: (vehicle?.initial_km ?? 0),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { config } from "../../config/env";
|
|||||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
@@ -1294,6 +1295,41 @@ export default async function warehouseRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /receipts/:id/attachments/:attachmentId — download attachment from NAS
|
||||||
|
fastify.get<{ Params: { id: string; attachmentId: string } }>(
|
||||||
|
"/receipts/:id/attachments/:attachmentId",
|
||||||
|
{ preHandler: requirePermission("warehouse.view") },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseId(request.params.id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const attachmentId = parseId(request.params.attachmentId, reply);
|
||||||
|
if (attachmentId === null) return;
|
||||||
|
|
||||||
|
const attachment = await prisma.sklad_receipt_attachments.findUnique({
|
||||||
|
where: { id: attachmentId },
|
||||||
|
});
|
||||||
|
// 404 (not 400) when the attachment belongs to a different receipt:
|
||||||
|
// a read endpoint should not confirm that the id exists elsewhere.
|
||||||
|
if (!attachment || attachment.receipt_id !== id) {
|
||||||
|
return error(reply, "Příloha nenalezena", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nasFile = nasInvoicesManager.readReceived(attachment.file_path);
|
||||||
|
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||||
|
|
||||||
|
const mime = attachment.file_mime || "application/octet-stream";
|
||||||
|
// "attachment": the UI always downloads via blob + a.download, never
|
||||||
|
// renders inline — and attachment is safer with client-controlled mime.
|
||||||
|
return reply
|
||||||
|
.type(mime)
|
||||||
|
.header(
|
||||||
|
"Content-Disposition",
|
||||||
|
contentDisposition("attachment", attachment.file_name),
|
||||||
|
)
|
||||||
|
.send(nasFile.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /receipts/:id/attachments — upload attachment
|
// POST /receipts/:id/attachments — upload attachment
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
"/receipts/:id/attachments",
|
"/receipts/:id/attachments",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
intIdFromForm,
|
intIdFromForm,
|
||||||
|
isoDateString,
|
||||||
|
nullableDateTimeString,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
numberFromForm,
|
numberFromForm,
|
||||||
numberInRange,
|
numberInRange,
|
||||||
@@ -66,19 +68,28 @@ export const AttendancePunchSchema = z.object({
|
|||||||
address: z.string().max(500).nullish(),
|
address: z.string().max(500).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// arrival/departure/break fields travel as COMBINED local datetimes
|
||||||
|
// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a
|
||||||
|
// date + time input) — the service parses them with `new Date(...)`. They are
|
||||||
|
// NOT bare HH:MM times.
|
||||||
export const CreateAttendanceSchema = z.object({
|
export const CreateAttendanceSchema = z.object({
|
||||||
user_id: intIdFromForm.optional(),
|
user_id: intIdFromForm.optional(),
|
||||||
shift_date: z.string(),
|
// Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
|
||||||
arrival_time: timeString.nullish(),
|
// duplicate-validation window and the stored @db.Date both depend on it.
|
||||||
|
// isoDateString leniently strips a trailing time component.
|
||||||
|
shift_date: isoDateString,
|
||||||
|
arrival_time: nullableDateTimeString.optional(),
|
||||||
arrival_lat: numberFromForm.nullish(),
|
arrival_lat: numberFromForm.nullish(),
|
||||||
arrival_lng: numberFromForm.nullish(),
|
arrival_lng: numberFromForm.nullish(),
|
||||||
arrival_accuracy: numberFromForm.nullish(),
|
arrival_accuracy: numberFromForm.nullish(),
|
||||||
arrival_address: z.string().nullish(),
|
arrival_address: z.string().nullish(),
|
||||||
departure_time: timeString.nullish(),
|
departure_time: nullableDateTimeString.optional(),
|
||||||
departure_lat: numberFromForm.nullish(),
|
departure_lat: numberFromForm.nullish(),
|
||||||
departure_lng: numberFromForm.nullish(),
|
departure_lng: numberFromForm.nullish(),
|
||||||
departure_accuracy: numberFromForm.nullish(),
|
departure_accuracy: numberFromForm.nullish(),
|
||||||
departure_address: z.string().nullish(),
|
departure_address: z.string().nullish(),
|
||||||
|
break_start: nullableDateTimeString.optional(),
|
||||||
|
break_end: nullableDateTimeString.optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
project_id: nullableIntIdFromForm.nullish(),
|
project_id: nullableIntIdFromForm.nullish(),
|
||||||
leave_type: z
|
leave_type: z
|
||||||
@@ -90,10 +101,10 @@ export const CreateAttendanceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateAttendanceSchema = z.object({
|
export const UpdateAttendanceSchema = z.object({
|
||||||
arrival_time: timeString.nullish(),
|
arrival_time: nullableDateTimeString.optional(),
|
||||||
departure_time: timeString.nullish(),
|
departure_time: nullableDateTimeString.optional(),
|
||||||
break_start: timeString.nullish(),
|
break_start: nullableDateTimeString.optional(),
|
||||||
break_end: timeString.nullish(),
|
break_end: nullableDateTimeString.optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
project_id: nullableIntIdFromForm.optional(),
|
project_id: nullableIntIdFromForm.optional(),
|
||||||
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),
|
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({
|
|||||||
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
|
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
|
||||||
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
|
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
|
||||||
.max(64, "Záložní kód je příliš dlouhý"),
|
.max(64, "Záložní kód je příliš dlouhý"),
|
||||||
|
remember_me: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TotpEnableSchema = z.object({
|
export const TotpEnableSchema = z.object({
|
||||||
|
|||||||
@@ -111,6 +111,40 @@ export const timeString = z.preprocess(
|
|||||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
|
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
|
||||||
|
* attendance forms produce by joining a date input and a time input
|
||||||
|
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
|
||||||
|
* stripped before validating), because the client appends ":00" and an edit
|
||||||
|
* form round-trips a value the `Date.toJSON` override serialised as
|
||||||
|
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
|
||||||
|
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
|
||||||
|
*/
|
||||||
|
export const dateTimeString = z.preprocess(
|
||||||
|
(v) =>
|
||||||
|
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
|
||||||
|
? v.slice(0, 16)
|
||||||
|
: v,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
|
||||||
|
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
|
||||||
|
* forms historically submitted empty strings for unfilled time fields, and a
|
||||||
|
* hard reject would 400 the whole form — including leave records that have no
|
||||||
|
* times at all). Pair with `.optional()` at the call site for fields that may
|
||||||
|
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
|
||||||
|
*/
|
||||||
|
export const nullableDateTimeString = z.preprocess(
|
||||||
|
(v) => (v === "" || v === null ? null : v),
|
||||||
|
z.union([z.null(), dateTimeString]),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An email field that also accepts an empty string (meaning "not set"). Many
|
* An email field that also accepts an empty string (meaning "not set"). Many
|
||||||
* settings/supplier forms submit "" for un-filled optional emails; a bare
|
* settings/supplier forms submit "" for un-filled optional emails; a bare
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const UpdateInvoiceSchema = z.object({
|
|||||||
bank_iban: z.string().max(255).nullish(),
|
bank_iban: z.string().max(255).nullish(),
|
||||||
bank_account: z.string().max(255).nullish(),
|
bank_account: z.string().max(255).nullish(),
|
||||||
issued_by: z.string().max(255).nullish(),
|
issued_by: z.string().max(255).nullish(),
|
||||||
|
billing_text: z.string().max(8000).nullish(),
|
||||||
customer_id: nullableIntIdFromForm.optional(),
|
customer_id: nullableIntIdFromForm.optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
vat_rate: numberInRange(0, 100).optional(),
|
||||||
apply_vat: booleanFromForm.optional(),
|
apply_vat: booleanFromForm.optional(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ISSUED_ORDER_STATUSES = [
|
|||||||
|
|
||||||
export const CreateIssuedOrderSchema = z.object({
|
export const CreateIssuedOrderSchema = z.object({
|
||||||
po_number: z.string().max(50).nullish(),
|
po_number: z.string().max(50).nullish(),
|
||||||
customer_id: nullableIntIdFromForm.nullish(),
|
supplier_id: nullableIntIdFromForm.nullish(),
|
||||||
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
||||||
currency: z.string().max(10).optional(),
|
currency: z.string().max(10).optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
vat_rate: numberInRange(0, 100).optional(),
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateTripSchema = z.object({
|
export const UpdateTripSchema = z.object({
|
||||||
|
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
|
||||||
|
vehicle_id: intIdFromForm.optional(),
|
||||||
trip_date: isoDateString.optional(),
|
trip_date: isoDateString.optional(),
|
||||||
start_km: nonNegativeNumberFromForm.optional(),
|
start_km: nonNegativeNumberFromForm.optional(),
|
||||||
end_km: nonNegativeNumberFromForm.optional(),
|
end_km: nonNegativeNumberFromForm.optional(),
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface CreateAttendanceData {
|
|||||||
departure_lng?: number | null;
|
departure_lng?: number | null;
|
||||||
departure_accuracy?: number | null;
|
departure_accuracy?: number | null;
|
||||||
departure_address?: string | null;
|
departure_address?: string | null;
|
||||||
|
break_start?: string | null;
|
||||||
|
break_end?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
leave_type: string;
|
leave_type: string;
|
||||||
@@ -187,12 +189,16 @@ export async function getStatus(userId: number) {
|
|||||||
const y = now.getFullYear(),
|
const y = now.getFullYear(),
|
||||||
m = now.getMonth(),
|
m = now.getMonth(),
|
||||||
d = now.getDate();
|
d = now.getDate();
|
||||||
const todayStart = new Date(y, m, d, 0, 0, 0);
|
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
|
||||||
const todayEnd = new Date(y, m, d, 23, 59, 59);
|
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
|
||||||
|
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
|
||||||
|
// one day back. Half-open [gte, lt) ranges.
|
||||||
|
const todayStart = new Date(Date.UTC(y, m, d));
|
||||||
|
const todayEnd = new Date(Date.UTC(y, m, d + 1));
|
||||||
|
|
||||||
// Monthly fund range (used by query #4)
|
// Monthly fund range (used by query #4)
|
||||||
const monthStart = new Date(y, m, 1);
|
const monthStart = new Date(Date.UTC(y, m, 1));
|
||||||
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
|
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
|
||||||
|
|
||||||
// Queries 1-4 are independent of one another → run them in parallel.
|
// Queries 1-4 are independent of one another → run them in parallel.
|
||||||
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
||||||
@@ -210,7 +216,7 @@ export async function getStatus(userId: number) {
|
|||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: { gte: todayStart, lte: todayEnd },
|
shift_date: { gte: todayStart, lt: todayEnd },
|
||||||
departure_time: { not: null },
|
departure_time: { not: null },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -226,7 +232,7 @@ export async function getStatus(userId: number) {
|
|||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: { gte: monthStart, lte: monthEnd },
|
shift_date: { gte: monthStart, lt: monthEnd },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -583,10 +589,13 @@ export async function getWorkfund(year: number) {
|
|||||||
: bizDays;
|
: bizDays;
|
||||||
const fund = bizDays * 8;
|
const fund = bizDays * 8;
|
||||||
const fundToDate = bizDaysToDate * 8;
|
const fundToDate = bizDaysToDate * 8;
|
||||||
const monthStart = new Date(year, m, 1);
|
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
|
||||||
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
|
// month boundaries, half-open range. Local midnights would double-count
|
||||||
|
// each previous month's last day.
|
||||||
|
const monthStart = new Date(Date.UTC(year, m, 1));
|
||||||
|
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
|
||||||
const monthRecords = await prisma.attendance.findMany({
|
const monthRecords = await prisma.attendance.findMany({
|
||||||
where: { shift_date: { gte: monthStart, lte: monthEnd } },
|
where: { shift_date: { gte: monthStart, lt: monthEnd } },
|
||||||
select: {
|
select: {
|
||||||
user_id: true,
|
user_id: true,
|
||||||
shift_date: true,
|
shift_date: true,
|
||||||
@@ -844,13 +853,16 @@ export async function getPrintData(
|
|||||||
const yr = Number(yearStr);
|
const yr = Number(yearStr);
|
||||||
const mo = Number(monthNumStr);
|
const mo = Number(monthNumStr);
|
||||||
|
|
||||||
const monthStart = new Date(yr, mo - 1, 1);
|
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
|
||||||
const monthEnd = new Date(yr, mo, 0, 23, 59, 59);
|
// boundaries, half-open range (local midnights pulled in the previous
|
||||||
|
// month's last day).
|
||||||
|
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
|
||||||
|
const monthEnd = new Date(Date.UTC(yr, mo, 1));
|
||||||
|
|
||||||
const users = await getAttendanceUsers();
|
const users = await getAttendanceUsers();
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
shift_date: { gte: monthStart, lte: monthEnd },
|
shift_date: { gte: monthStart, lt: monthEnd },
|
||||||
};
|
};
|
||||||
if (filterUserId) where.user_id = filterUserId;
|
if (filterUserId) where.user_id = filterUserId;
|
||||||
|
|
||||||
@@ -1041,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) {
|
|||||||
where.user_id = params.userId;
|
where.user_id = params.userId;
|
||||||
}
|
}
|
||||||
if (params.month && params.year) {
|
if (params.month && params.year) {
|
||||||
|
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back (included prev-month's last day, dropped this month's last day).
|
||||||
where.shift_date = {
|
where.shift_date = {
|
||||||
gte: new Date(params.year, params.month - 1, 1),
|
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
|
||||||
lt: new Date(params.year, params.month, 1),
|
lt: new Date(Date.UTC(params.year, params.month, 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,15 +1540,25 @@ export async function createAttendance(
|
|||||||
) {
|
) {
|
||||||
const userId = data.user_id ?? authUserId;
|
const userId = data.user_id ?? authUserId;
|
||||||
const shiftDate = new Date(data.shift_date);
|
const shiftDate = new Date(data.shift_date);
|
||||||
|
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
|
||||||
|
// @db.Date column is compared by UTC date part. The duplicate/overlap
|
||||||
|
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
|
||||||
|
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
|
||||||
|
// day, so the validation queried ONLY yesterday's records (same-day
|
||||||
|
// overlaps undetected, false duplicates when yesterday had records).
|
||||||
const startOfDay = new Date(
|
const startOfDay = new Date(
|
||||||
shiftDate.getFullYear(),
|
Date.UTC(
|
||||||
shiftDate.getMonth(),
|
shiftDate.getUTCFullYear(),
|
||||||
shiftDate.getDate(),
|
shiftDate.getUTCMonth(),
|
||||||
|
shiftDate.getUTCDate(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const endOfDay = new Date(
|
const endOfDay = new Date(
|
||||||
shiftDate.getFullYear(),
|
Date.UTC(
|
||||||
shiftDate.getMonth(),
|
shiftDate.getUTCFullYear(),
|
||||||
shiftDate.getDate() + 1,
|
shiftDate.getUTCMonth(),
|
||||||
|
shiftDate.getUTCDate() + 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Multiple work shifts per day are allowed — only block when the new
|
// Multiple work shifts per day are allowed — only block when the new
|
||||||
@@ -1608,6 +1633,8 @@ export async function createAttendance(
|
|||||||
departure_lng: data.departure_lng ?? null,
|
departure_lng: data.departure_lng ?? null,
|
||||||
departure_accuracy: data.departure_accuracy ?? null,
|
departure_accuracy: data.departure_accuracy ?? null,
|
||||||
departure_address: data.departure_address ?? null,
|
departure_address: data.departure_address ?? null,
|
||||||
|
break_start: data.break_start ? new Date(data.break_start) : null,
|
||||||
|
break_end: data.break_end ? new Date(data.break_end) : null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
project_id: data.project_id ?? null,
|
project_id: data.project_id ?? null,
|
||||||
leave_type: data.leave_type as attendance_leave_type,
|
leave_type: data.leave_type as attendance_leave_type,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import { sendMail } from "./mailer";
|
import { sendMail } from "./mailer";
|
||||||
import { localDateCzStr, localDateStr } from "../utils/date";
|
import {
|
||||||
|
localDateCzStr,
|
||||||
|
localDateStr,
|
||||||
|
utcMidnightOfLocalDay,
|
||||||
|
} from "../utils/date";
|
||||||
import { getSystemSettings } from "./system-settings";
|
import { getSystemSettings } from "./system-settings";
|
||||||
|
|
||||||
interface AlertInvoice {
|
interface AlertInvoice {
|
||||||
@@ -33,17 +37,35 @@ function formatAmount(n: number | { toNumber?: () => number }): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert window [today, today+3] for the due-date queries + the local date
|
||||||
|
* strings the classifier matches against.
|
||||||
|
*
|
||||||
|
* due_date is @db.Date: Prisma truncates the filter Dates to their UTC date
|
||||||
|
* part, so the window boundaries must be UTC midnights of the LOCAL calendar
|
||||||
|
* day. A local midnight (22:00/23:00 UTC of the previous day) shifted the
|
||||||
|
* whole window a day early — the "splatnost za 3 dny" advance alert
|
||||||
|
* (due == today+3) could then never fire.
|
||||||
|
*
|
||||||
|
* Exported (with an injectable `now`) so the window math is unit-testable.
|
||||||
|
*/
|
||||||
|
export function computeAlertWindow(now: Date = new Date()) {
|
||||||
|
const today = utcMidnightOfLocalDay(now);
|
||||||
|
const todayStr = localDateStr(now);
|
||||||
|
const in3days = new Date(today);
|
||||||
|
in3days.setUTCDate(in3days.getUTCDate() + 3);
|
||||||
|
const in3daysStr = localDateStr(
|
||||||
|
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3),
|
||||||
|
);
|
||||||
|
return { today, todayStr, in3days, in3daysStr };
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkInvoiceAlerts(): Promise<void> {
|
export async function checkInvoiceAlerts(): Promise<void> {
|
||||||
const settings = await getSystemSettings();
|
const settings = await getSystemSettings();
|
||||||
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
|
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
|
||||||
if (!alertEmail) return;
|
if (!alertEmail) return;
|
||||||
|
|
||||||
const today = new Date();
|
const { today, todayStr, in3days, in3daysStr } = computeAlertWindow();
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const todayStr = localDateStr(today);
|
|
||||||
const in3days = new Date(today);
|
|
||||||
in3days.setDate(in3days.getDate() + 3);
|
|
||||||
const in3daysStr = localDateStr(in3days);
|
|
||||||
|
|
||||||
// Classify a due date into an alert type/label, or null if it doesn't match.
|
// Classify a due date into an alert type/label, or null if it doesn't match.
|
||||||
const classify = (
|
const classify = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
import { toCzk } from "./exchange-rates";
|
import { toCzk } from "./exchange-rates";
|
||||||
import {
|
import {
|
||||||
generateInvoiceNumber,
|
generateInvoiceNumber,
|
||||||
@@ -139,8 +140,12 @@ function buildInvoiceWhere(
|
|||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
if (customer_id) where.customer_id = customer_id;
|
if (customer_id) where.customer_id = customer_id;
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
const from = new Date(year, month - 1, 1);
|
// issue_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
const to = new Date(year, month, 1);
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back — the filter included the previous month's last day and
|
||||||
|
// DROPPED invoices issued on the selected month's last day.
|
||||||
|
const from = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const to = new Date(Date.UTC(year, month, 1));
|
||||||
where.issue_date = { gte: from, lt: to };
|
where.issue_date = { gte: from, lt: to };
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -180,13 +185,18 @@ function computeInvoiceTotals(
|
|||||||
|
|
||||||
export async function markOverdueInvoices() {
|
export async function markOverdueInvoices() {
|
||||||
try {
|
try {
|
||||||
|
// due_date is @db.Date (UTC midnight of the calendar day). Compare
|
||||||
|
// against UTC midnight of the LOCAL today — a bare new Date() has
|
||||||
|
// yesterday's UTC date during the 00:00–02:00 Prague window, so the
|
||||||
|
// overdue flip lagged a day there. Due TODAY = not yet overdue.
|
||||||
|
const today = utcMidnightOfLocalDay();
|
||||||
await prisma.invoices.updateMany({
|
await prisma.invoices.updateMany({
|
||||||
where: { status: "issued", due_date: { lt: new Date() } },
|
where: { status: "issued", due_date: { lt: today } },
|
||||||
data: { status: "overdue" },
|
data: { status: "overdue" },
|
||||||
});
|
});
|
||||||
// Reverse: if due_date was changed to future, set back to issued
|
// Reverse: if due_date was changed to today/future, set back to issued
|
||||||
await prisma.invoices.updateMany({
|
await prisma.invoices.updateMany({
|
||||||
where: { status: "overdue", due_date: { gte: new Date() } },
|
where: { status: "overdue", due_date: { gte: today } },
|
||||||
data: { status: "issued" },
|
data: { status: "issued" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -312,10 +322,13 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
const year = queryYear || now.getFullYear();
|
const year = queryYear || now.getFullYear();
|
||||||
const month = queryMonth || now.getMonth() + 1;
|
const month = queryMonth || now.getMonth() + 1;
|
||||||
|
|
||||||
const monthStart = new Date(year, month - 1, 1);
|
// issue_date is @db.Date (compared by UTC date part) → UTC-midnight period
|
||||||
const monthEnd = new Date(year, month, 0, 23, 59, 59);
|
// boundaries, half-open [gte, lt) ranges. Local-midnight bounds included
|
||||||
const startOfYear = new Date(year, 0, 1);
|
// the previous period's boundary day in the stats.
|
||||||
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
|
const monthStart = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const nextMonthStart = new Date(Date.UTC(year, month, 1));
|
||||||
|
const startOfYear = new Date(Date.UTC(year, 0, 1));
|
||||||
|
const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1));
|
||||||
|
|
||||||
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
@@ -323,21 +336,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
// Drafts are not real financial documents — exclude them so their
|
// Drafts are not real financial documents — exclude them so their
|
||||||
// VAT/amounts never inflate the monthly stats (vat_month etc.).
|
// VAT/amounts never inflate the monthly stats (vat_month etc.).
|
||||||
status: { not: "draft" },
|
status: { not: "draft" },
|
||||||
issue_date: { gte: monthStart, lte: monthEnd },
|
issue_date: { gte: monthStart, lt: nextMonthStart },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "issued",
|
status: "issued",
|
||||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
issue_date: { gte: startOfYear, lt: startOfNextYear },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "overdue",
|
status: "overdue",
|
||||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
issue_date: { gte: startOfYear, lt: startOfNextYear },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
@@ -430,6 +443,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
export async function getOrderDataForInvoice(orderId: number) {
|
export async function getOrderDataForInvoice(orderId: number) {
|
||||||
const order = await prisma.orders.findUnique({
|
const order = await prisma.orders.findUnique({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
|
// This result is spread into the order-data API response — never include
|
||||||
|
// the PO attachment blob (served only by GET /orders/:id/attachment).
|
||||||
|
omit: { attachment_data: true },
|
||||||
include: {
|
include: {
|
||||||
customers: true,
|
customers: true,
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order_items: { orderBy: { position: "asc" } },
|
||||||
@@ -598,9 +614,11 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
// Status change
|
// Status change
|
||||||
if (body.status !== undefined) {
|
if (body.status !== undefined) {
|
||||||
data.status = String(body.status);
|
data.status = String(body.status);
|
||||||
// Auto-set paid_date when transitioning to paid
|
// Auto-set paid_date when transitioning to paid. paid_date is @db.Date
|
||||||
|
// (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the
|
||||||
|
// LOCAL calendar day even during the 00:00–02:00 Prague window.
|
||||||
if (String(body.status) === "paid" && !existing.paid_date) {
|
if (String(body.status) === "paid" && !existing.paid_date) {
|
||||||
data.paid_date = new Date();
|
data.paid_date = utcMidnightOfLocalDay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { $Enums } from "@prisma/client";
|
import type { $Enums } from "@prisma/client";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
import {
|
import {
|
||||||
generateIssuedOrderNumber,
|
generateIssuedOrderNumber,
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
@@ -19,7 +20,7 @@ export interface IssuedOrderItemInput {
|
|||||||
|
|
||||||
export interface IssuedOrderInput {
|
export interface IssuedOrderInput {
|
||||||
po_number?: string | number | null;
|
po_number?: string | number | null;
|
||||||
customer_id?: number | string | null;
|
supplier_id?: number | string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
vat_rate?: number | string | null;
|
vat_rate?: number | string | null;
|
||||||
@@ -40,7 +41,7 @@ export interface IssuedOrderInput {
|
|||||||
interface IssuedOrderFilterParams {
|
interface IssuedOrderFilterParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
supplier_id?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
@@ -66,20 +67,23 @@ export interface CurrencyAmount {
|
|||||||
function buildIssuedOrderWhere(
|
function buildIssuedOrderWhere(
|
||||||
params: IssuedOrderFilterParams,
|
params: IssuedOrderFilterParams,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const { search, status, customer_id, month, year } = params;
|
const { search, status, supplier_id, month, year } = params;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
if (customer_id) where.customer_id = customer_id;
|
if (supplier_id) where.supplier_id = supplier_id;
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ po_number: { contains: search } },
|
{ po_number: { contains: search } },
|
||||||
{ customers: { name: { contains: search } } },
|
{ suppliers: { name: { contains: search } } },
|
||||||
{ customers: { company_id: { contains: search } } },
|
{ suppliers: { ico: { contains: search } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
const from = new Date(year, month - 1, 1);
|
// order_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
const to = new Date(year, month, 1);
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back (included prev-month's last day, dropped this month's last day).
|
||||||
|
const from = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const to = new Date(Date.UTC(year, month, 1));
|
||||||
where.order_date = { gte: from, lt: to };
|
where.order_date = { gte: from, lt: to };
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
@@ -150,7 +154,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
take: limit,
|
take: limit,
|
||||||
orderBy,
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
customers: { select: { id: true, name: true } },
|
suppliers: { select: { id: true, name: true } },
|
||||||
issued_order_items: true,
|
issued_order_items: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -167,7 +171,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: issued_order_items,
|
items: issued_order_items,
|
||||||
customer_name: o.customers?.name || null,
|
supplier_name: o.suppliers?.name || null,
|
||||||
...totals,
|
...totals,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -217,7 +221,19 @@ export async function getIssuedOrder(id: number) {
|
|||||||
const order = await prisma.issued_orders.findUnique({
|
const order = await prisma.issued_orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
customers: true,
|
// Same minimal field set as the picker lookup endpoint — the full row
|
||||||
|
// would expose internal notes/contact to any orders.view holder.
|
||||||
|
suppliers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ico: true,
|
||||||
|
dic: true,
|
||||||
|
address: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
issued_order_items: { orderBy: { position: "asc" } },
|
issued_order_items: { orderBy: { position: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,8 +242,8 @@ export async function getIssuedOrder(id: number) {
|
|||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: issued_order_items,
|
items: issued_order_items,
|
||||||
customer: order.customers,
|
supplier: order.suppliers,
|
||||||
customer_name: order.customers?.name || null,
|
supplier_name: order.suppliers?.name || null,
|
||||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -236,6 +252,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const status = body.status ? String(body.status) : "draft";
|
const status = body.status ? String(body.status) : "draft";
|
||||||
|
|
||||||
|
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||||
|
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||||
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
|
if (supplierId) {
|
||||||
|
const supplier = await tx.sklad_suppliers.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!supplier) return { error: "supplier_not_found" as const };
|
||||||
|
}
|
||||||
|
|
||||||
// Deferred numbering: a draft carries NO po_number (the column is
|
// Deferred numbering: a draft carries NO po_number (the column is
|
||||||
// nullable-unique so many drafts coexist). The official number is consumed
|
// nullable-unique so many drafts coexist). The official number is consumed
|
||||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||||
@@ -255,16 +282,19 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
const order = await tx.issued_orders.create({
|
const order = await tx.issued_orders.create({
|
||||||
data: {
|
data: {
|
||||||
po_number: poNumber,
|
po_number: poNumber,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
supplier_id: supplierId,
|
||||||
status: status as $Enums.issued_orders_status,
|
status: status as $Enums.issued_orders_status,
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
exchange_rate:
|
exchange_rate:
|
||||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||||
|
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||||
|
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||||
|
// yesterday during the 00:00–02:00 Prague window.
|
||||||
order_date: body.order_date
|
order_date: body.order_date
|
||||||
? new Date(String(body.order_date))
|
? new Date(String(body.order_date))
|
||||||
: new Date(),
|
: utcMidnightOfLocalDay(),
|
||||||
delivery_date: body.delivery_date
|
delivery_date: body.delivery_date
|
||||||
? new Date(String(body.delivery_date))
|
? new Date(String(body.delivery_date))
|
||||||
: null,
|
: null,
|
||||||
@@ -328,8 +358,18 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
for (const f of strFields) {
|
for (const f of strFields) {
|
||||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||||
}
|
}
|
||||||
if (body.customer_id !== undefined)
|
if (body.supplier_id !== undefined) {
|
||||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
|
if (supplierId) {
|
||||||
|
// Same dangling-FK guard as create: 400, not a P2003 500.
|
||||||
|
const supplier = await prisma.sklad_suppliers.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!supplier) return { error: "supplier_not_found" as const };
|
||||||
|
}
|
||||||
|
data.supplier_id = supplierId;
|
||||||
|
}
|
||||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||||
if (body.apply_vat !== undefined)
|
if (body.apply_vat !== undefined)
|
||||||
data.apply_vat =
|
data.apply_vat =
|
||||||
|
|||||||
@@ -206,8 +206,16 @@ async function releaseSequence(
|
|||||||
/** Verify a shared number is not already used by an order or project. */
|
/** Verify a shared number is not already used by an order or project. */
|
||||||
export async function isSharedNumberTaken(number: string): Promise<boolean> {
|
export async function isSharedNumberTaken(number: string): Promise<boolean> {
|
||||||
const [existingOrder, existingProject] = await Promise.all([
|
const [existingOrder, existingProject] = await Promise.all([
|
||||||
prisma.orders.findFirst({ where: { order_number: number } }),
|
// select id only — the orders row carries the PO attachment blob, which an
|
||||||
prisma.projects.findFirst({ where: { project_number: number } }),
|
// existence check must never pull.
|
||||||
|
prisma.orders.findFirst({
|
||||||
|
where: { order_number: number },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.projects.findFirst({
|
||||||
|
where: { project_number: number },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
return !!(existingOrder || existingProject);
|
return !!(existingOrder || existingProject);
|
||||||
}
|
}
|
||||||
@@ -238,8 +246,11 @@ export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
|||||||
|
|
||||||
/** Verify an order number is not already used. */
|
/** Verify an order number is not already used. */
|
||||||
export async function isOrderNumberTaken(number: string): Promise<boolean> {
|
export async function isOrderNumberTaken(number: string): Promise<boolean> {
|
||||||
|
// select id only — the orders row carries the PO attachment blob, which an
|
||||||
|
// existence check must never pull.
|
||||||
const existing = await prisma.orders.findFirst({
|
const existing = await prisma.orders.findFirst({
|
||||||
where: { order_number: number },
|
where: { order_number: number },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
return !!existing;
|
return !!existing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ async function syncProjectStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat,
|
||||||
|
// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW
|
||||||
|
// order/item field here, add it to that select — a field missing from the
|
||||||
|
// select is silently 0 in the per-currency totals.
|
||||||
function enrichOrder(o: any) {
|
function enrichOrder(o: any) {
|
||||||
const subtotal = o.order_items
|
const subtotal = o.order_items
|
||||||
.filter((i: any) => i.is_included_in_total !== false)
|
.filter((i: any) => i.is_included_in_total !== false)
|
||||||
@@ -158,6 +162,11 @@ export async function listOrders(params: ListOrdersParams) {
|
|||||||
take: limit,
|
take: limit,
|
||||||
// Tiebreak on id so same-second created_at rows sort deterministically.
|
// Tiebreak on id so same-second created_at rows sort deterministically.
|
||||||
orderBy: [{ [sortField]: order }, { id: order }],
|
orderBy: [{ [sortField]: order }, { id: order }],
|
||||||
|
// The PO attachment blob is served ONLY by GET /:id/attachment. Never
|
||||||
|
// pull it into list payloads — a single 1MB PDF would serialize into
|
||||||
|
// megabytes of JSON per row. attachment_name stays as the existence
|
||||||
|
// signal the frontend uses.
|
||||||
|
omit: { attachment_data: true },
|
||||||
include: {
|
include: {
|
||||||
customers: { select: { id: true, name: true } },
|
customers: { select: { id: true, name: true } },
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order_items: { orderBy: { position: "asc" } },
|
||||||
@@ -193,17 +202,21 @@ export async function getOrderTotals(
|
|||||||
): Promise<{ totals: CurrencyAmount[] }> {
|
): Promise<{ totals: CurrencyAmount[] }> {
|
||||||
const where = buildOrderWhere(params);
|
const where = buildOrderWhere(params);
|
||||||
|
|
||||||
|
// Select ONLY what the math needs (currency + VAT flags + item numbers).
|
||||||
|
// This runs over the WHOLE filtered set, so pulling attachment blobs,
|
||||||
|
// sections HTML or relations here would multiply the query size for nothing.
|
||||||
const orders = await prisma.orders.findMany({
|
const orders = await prisma.orders.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
select: {
|
||||||
customers: { select: { id: true, name: true } },
|
currency: true,
|
||||||
order_items: { orderBy: { position: "asc" } },
|
apply_vat: true,
|
||||||
order_sections: { orderBy: { position: "asc" } },
|
vat_rate: true,
|
||||||
quotations: { select: { quotation_number: true, project_code: true } },
|
order_items: {
|
||||||
invoices: {
|
select: {
|
||||||
select: { id: true, invoice_number: true },
|
quantity: true,
|
||||||
take: 1,
|
unit_price: true,
|
||||||
orderBy: { id: "desc" },
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -228,6 +241,9 @@ export async function getOrderTotals(
|
|||||||
export async function getOrder(id: number) {
|
export async function getOrder(id: number) {
|
||||||
const order = await prisma.orders.findUnique({
|
const order = await prisma.orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
// The blob belongs only to GET /:id/attachment — the detail payload
|
||||||
|
// carries attachment_name as the existence signal.
|
||||||
|
omit: { attachment_data: true },
|
||||||
include: {
|
include: {
|
||||||
customers: true,
|
customers: true,
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order_items: { orderBy: { position: "asc" } },
|
||||||
@@ -567,7 +583,11 @@ interface UpdateOrderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOrder(id: number, body: UpdateOrderData) {
|
export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||||
const existing = await prisma.orders.findUnique({ where: { id } });
|
// Pre-read only the fields the guards below check — never the PDF blob.
|
||||||
|
const existing = await prisma.orders.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { status: true, order_number: true },
|
||||||
|
});
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||||
|
|
||||||
@@ -680,7 +700,11 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOrder(id: number, deleteFiles = false) {
|
export async function deleteOrder(id: number, deleteFiles = false) {
|
||||||
const existing = await prisma.orders.findUnique({ where: { id } });
|
// Pre-read only what the delete needs (number release + year) — not the blob.
|
||||||
|
const existing = await prisma.orders.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { order_number: true, created_at: true },
|
||||||
|
});
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||||
|
|
||||||
|
|||||||
28
src/utils/content-disposition.ts
Normal file
28
src/utils/content-disposition.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Build a Content-Disposition header value that is safe for non-ASCII
|
||||||
|
* filenames (Czech diacritics are the norm here: "příloha č. 1.pdf").
|
||||||
|
*
|
||||||
|
* Node's HTTP layer THROWS on header values containing characters above
|
||||||
|
* U+00FF, so the raw filename must never be interpolated into the header
|
||||||
|
* directly — that turns a download of a diacritic-named file into a 500.
|
||||||
|
* Per RFC 5987 / RFC 6266 the real UTF-8 name is carried in the `filename*`
|
||||||
|
* parameter (percent-encoded), while `filename` holds an ASCII-only fallback
|
||||||
|
* for legacy clients. Modern browsers prefer `filename*`.
|
||||||
|
*/
|
||||||
|
export function contentDisposition(
|
||||||
|
type: "inline" | "attachment",
|
||||||
|
fileName: string,
|
||||||
|
): string {
|
||||||
|
const asciiFallback = fileName
|
||||||
|
.replace(/[^\x20-\x7e]/g, "_")
|
||||||
|
.replace(/["\\]/g, "");
|
||||||
|
// encodeURIComponent leaves ' ( ) * raw, but they are not RFC 5987
|
||||||
|
// attr-chars (the apostrophe is even the ext-value delimiter) — strict
|
||||||
|
// parsers would reject the filename* and fall back to the mangled ASCII
|
||||||
|
// name. Percent-encode them like the `content-disposition` npm package.
|
||||||
|
const encoded = encodeURIComponent(fileName).replace(
|
||||||
|
/['()*]/g,
|
||||||
|
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
@@ -8,6 +8,22 @@
|
|||||||
* of JSON serialization (e.g., building lookup keys, shift_date strings).
|
* of JSON serialization (e.g., building lookup keys, shift_date strings).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC midnight of the LOCAL calendar day of `d` (default: now).
|
||||||
|
*
|
||||||
|
* Prisma truncates a JS Date used against a `@db.Date` column (filter or
|
||||||
|
* write) to its **UTC** date part. With TZ=Europe/Prague, a local-midnight
|
||||||
|
* Date — `new Date(y, m, d)` — is 22:00/23:00 UTC of the PREVIOUS day, so it
|
||||||
|
* filters/stores as the previous calendar date; and a bare `new Date()`
|
||||||
|
* written to a `@db.Date` column stores yesterday during the 00:00–02:00
|
||||||
|
* Prague window. Always build `@db.Date` values and range boundaries from
|
||||||
|
* `Date.UTC(...)` of the LOCAL calendar components — this helper does exactly
|
||||||
|
* that for "today" (or any reference Date).
|
||||||
|
*/
|
||||||
|
export function utcMidnightOfLocalDay(d: Date = new Date()): Date {
|
||||||
|
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||||
|
}
|
||||||
|
|
||||||
/** YYYY-MM-DD in local time */
|
/** YYYY-MM-DD in local time */
|
||||||
export function localDateStr(d: Date): string {
|
export function localDateStr(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
|
|||||||
Reference in New Issue
Block a user