fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
.env.example
62
.env.example
@@ -1,32 +1,62 @@
|
|||||||
# Database
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
|
||||||
|
# Required vars throw at boot if missing; optional vars show their defaults.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Required ──────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=mysql://user:password@localhost:3306/app
|
DATABASE_URL=mysql://user:password@localhost:3306/app
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=local
|
|
||||||
|
|
||||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||||
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
|
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||||
|
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# ── Server ────────────────────────────────────────────────────────────────
|
||||||
|
PORT=3001 # production port (dev is hardcoded to 3050)
|
||||||
|
HOST=127.0.0.1
|
||||||
|
APP_ENV=local # local | production — controls CSP/CORS/HSTS
|
||||||
|
APP_URL= # base URL used in email links
|
||||||
|
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
|
||||||
|
|
||||||
|
# ── Tokens (seconds) ──────────────────────────────────────────────────────
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
ACCESS_TOKEN_EXPIRY=900
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||||
|
|
||||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
|
||||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
# TOTP_ALGORITHM=SHA1
|
||||||
|
# TOTP_DIGITS=6
|
||||||
|
# TOTP_PERIOD=30
|
||||||
|
# LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||||
|
|
||||||
# File storage
|
# ── Rate limiting (optional) ──────────────────────────────────────────────
|
||||||
|
# RATE_LIMIT_MAX=300
|
||||||
|
# RATE_LIMIT_WINDOW=1 minute
|
||||||
|
# RATE_LIMIT_LOGIN=20
|
||||||
|
# RATE_LIMIT_TOTP=5
|
||||||
|
# RATE_LIMIT_REFRESH=10
|
||||||
|
|
||||||
|
# ── File storage (NAS network shares) ─────────────────────────────────────
|
||||||
NAS_PATH=Z:/02_PROJEKTY
|
NAS_PATH=Z:/02_PROJEKTY
|
||||||
MAX_UPLOAD_SIZE=52428800
|
# NAS_FINANCIALS_PATH=
|
||||||
|
# NAS_OFFERS_PATH=
|
||||||
|
MAX_UPLOAD_SIZE=52428800 # 50 MB
|
||||||
|
|
||||||
# Email
|
# ── Email ─────────────────────────────────────────────────────────────────
|
||||||
CONTACT_EMAIL_TO=
|
CONTACT_EMAIL_TO=
|
||||||
CONTACT_EMAIL_FROM=
|
CONTACT_EMAIL_FROM=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
# SMTP_FROM_NAME=
|
||||||
LEAVE_NOTIFY_EMAIL=
|
LEAVE_NOTIFY_EMAIL=
|
||||||
|
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
|
||||||
|
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
|
||||||
|
# SMTP_HOST=
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=
|
||||||
|
# SMTP_PASS=
|
||||||
|
|
||||||
# App URL (for email links)
|
# ── CORS (production only, comma-separated) ───────────────────────────────
|
||||||
APP_URL=
|
|
||||||
|
|
||||||
# CORS (production only, comma-separated)
|
|
||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
|
|
||||||
|
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
|
||||||
|
# ANTHROPIC_API_KEY=
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,6 +7,13 @@ dist/
|
|||||||
dist-client/
|
dist-client/
|
||||||
*.css.map
|
*.css.map
|
||||||
|
|
||||||
|
# Build / tooling artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Local scratch
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Release archives
|
# Release archives
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
|||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dist/
|
||||||
|
dist-client/
|
||||||
|
node_modules/
|
||||||
|
prisma/migrations/
|
||||||
|
src/admin/bones/
|
||||||
|
*.tsbuildinfo
|
||||||
|
package-lock.json
|
||||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
78
eslint.config.mjs
Normal file
78
eslint.config.mjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"dist-client/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"prisma/migrations/**",
|
||||||
|
"src/admin/bones/**",
|
||||||
|
"*.config.js",
|
||||||
|
"*.config.mjs",
|
||||||
|
"*.config.ts",
|
||||||
|
".claude/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
// Server + shared code
|
||||||
|
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.browser },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// TS already resolves identifiers — `no-undef` only false-positives on
|
||||||
|
// global types/ambient names in .ts files.
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-require-imports": "warn",
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: false }],
|
||||||
|
// Intentional control-character regexes (NUL/path-traversal guards,
|
||||||
|
// combining-marks stripping) — not a defect here.
|
||||||
|
"no-control-regex": "off",
|
||||||
|
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-useless-assignment": "warn",
|
||||||
|
"preserve-caught-error": "warn",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Frontend React code
|
||||||
|
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
|
||||||
|
files: ["scripts/**/*.{js,mjs,cjs}"],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
rules: { "no-undef": "off" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Tests
|
||||||
|
files: ["src/__tests__/**/*.ts"],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
);
|
||||||
1537
package-lock.json
generated
1537
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -18,7 +18,12 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
@@ -52,7 +57,6 @@
|
|||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"file-type": "^16.5.4",
|
"file-type": "^16.5.4",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -63,18 +67,17 @@
|
|||||||
"puppeteer": "^24.40.0",
|
"puppeteer": "^24.40.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-datepicker": "^9.1.0",
|
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-quill-new": "^3.8.3",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@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/mysql": "^2.15.27",
|
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
@@ -84,9 +87,16 @@
|
|||||||
"@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",
|
"concurrently": "^9.2.1",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.61.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,22 @@ const PERMISSIONS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and
|
||||||
|
// role_permissions (and seeds an admin/admin user) — purely dev-convenience
|
||||||
|
// behavior. Production baseline data must come from tracked Prisma migrations,
|
||||||
|
// never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on
|
||||||
|
// production so the destructive wipe can never hit a live database.
|
||||||
|
if (process.env.APP_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " +
|
||||||
|
"Seed maže všechna oprávnění a je určen pouze pro vývoj. " +
|
||||||
|
"Produkční data musí být zavedena migrací.\n" +
|
||||||
|
"Refusing to run: prisma/seed.ts must NEVER run on production " +
|
||||||
|
"(APP_ENV=production). It wipes all permissions and is dev-only; " +
|
||||||
|
"seed production baseline data via a migration instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Seeding permissions...");
|
console.log("Seeding permissions...");
|
||||||
|
|
||||||
// 1. Clear existing permissions and re-insert current set
|
// 1. Clear existing permissions and re-insert current set
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import "../src/config/env";
|
import "../src/config/env";
|
||||||
|
import fs from "fs";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
|
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
|
||||||
|
|
||||||
@@ -51,6 +52,39 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-back verification before we null out the ONLY DB copy of the bytes.
|
||||||
|
// The manager uses writeFileSync (synchronous) and returns a relative path;
|
||||||
|
// resolve it back to disk and confirm the file exists and its size matches
|
||||||
|
// the source buffer. A 0-byte/truncated/corrupt write would otherwise lose
|
||||||
|
// the invoice permanently once file_data is set to null. If verification
|
||||||
|
// fails we KEEP file_data and report the row as failed.
|
||||||
|
const expectedSize = rec.file_data.length;
|
||||||
|
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
||||||
|
let writtenSize = -1;
|
||||||
|
if (readBack) {
|
||||||
|
writtenSize = readBack.data.length;
|
||||||
|
} else {
|
||||||
|
// Fall back to a direct stat in case readReceivedInvoice's path guard
|
||||||
|
// rejects an otherwise-valid path; either way we need a confirmed size.
|
||||||
|
try {
|
||||||
|
writtenSize = fs.statSync(
|
||||||
|
(nasFinancialsManager as unknown as { basePath: string }).basePath +
|
||||||
|
"/" +
|
||||||
|
result.filePath,
|
||||||
|
).size;
|
||||||
|
} catch {
|
||||||
|
writtenSize = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writtenSize !== expectedSize) {
|
||||||
|
console.error(
|
||||||
|
` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`,
|
||||||
|
);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.received_invoices.update({
|
await prisma.received_invoices.update({
|
||||||
where: { id: rec.id },
|
where: { id: rec.id },
|
||||||
data: { file_path: result.filePath, file_data: null },
|
data: { file_path: result.filePath, file_data: null },
|
||||||
|
|||||||
@@ -1,35 +1,29 @@
|
|||||||
import crypto from 'crypto';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function decrypt(ciphertext: string, keyHex: string): string {
|
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
|
||||||
const key = Buffer.from(keyHex, 'hex');
|
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
|
||||||
const [ivHex, encHex, tagHex] = ciphertext.split(':');
|
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
|
||||||
const encrypted = Buffer.from(encHex, 'hex');
|
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
|
||||||
const tag = Buffer.from(tagHex, 'hex');
|
// legacy user. These key-parameterized helpers accept the old/new keys passed on
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
|
||||||
decipher.setAuthTag(tag);
|
const decrypt = (ciphertext: string, keyHex: string): string =>
|
||||||
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
decryptWithKey(ciphertext, keyHex);
|
||||||
}
|
const encrypt = (plaintext: string, keyHex: string): string =>
|
||||||
|
encryptWithKey(plaintext, keyHex);
|
||||||
function encrypt(plaintext: string, keyHex: string): string {
|
|
||||||
const key = Buffer.from(keyHex, 'hex');
|
|
||||||
const iv = crypto.randomBytes(12);
|
|
||||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const oldKey = process.argv[2];
|
const oldKey = process.argv[2];
|
||||||
const newKey = process.argv[3];
|
const newKey = process.argv[3];
|
||||||
const dryRun = process.argv.includes('--dry-run');
|
const dryRun = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
if (!oldKey || !newKey) {
|
if (!oldKey || !newKey) {
|
||||||
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
|
console.error(
|
||||||
|
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +40,14 @@ async function main() {
|
|||||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||||
const reEncrypted = encrypt(decrypted, newKey);
|
const reEncrypted = encrypt(decrypted, newKey);
|
||||||
decrypt(reEncrypted, newKey); // verify round-trip
|
decrypt(reEncrypted, newKey); // verify round-trip
|
||||||
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
|
console.log(
|
||||||
|
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
|
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('\nDry run complete. No changes made.');
|
console.log("\nDry run complete. No changes made.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +55,19 @@ async function main() {
|
|||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const decrypted = decrypt(user.totp_secret!, oldKey);
|
const decrypted = decrypt(user.totp_secret!, oldKey);
|
||||||
const reEncrypted = encrypt(decrypted, newKey);
|
const reEncrypted = encrypt(decrypted, newKey);
|
||||||
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
|
await tx.users.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { totp_secret: reEncrypted },
|
||||||
|
});
|
||||||
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
|
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\nAll TOTP secrets re-encrypted successfully.');
|
console.log("\nAll TOTP secrets re-encrypted successfully.");
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,15 +65,21 @@ describe("ai.service cost + budget", () => {
|
|||||||
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sums only the current month's spend", async () => {
|
it("sums only the current month's spend (excludes prior months)", async () => {
|
||||||
|
// Measure the baseline so the assertion is robust to any OTHER current-month
|
||||||
|
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
|
||||||
|
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
|
||||||
|
// upper bound.
|
||||||
|
const before = await getMonthSpendUsd();
|
||||||
|
|
||||||
await recordUsage({
|
await recordUsage({
|
||||||
userId: null,
|
userId: null,
|
||||||
kind: KIND,
|
kind: KIND,
|
||||||
model: "claude-sonnet-4-6",
|
model: "claude-sonnet-4-6",
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
}); // $3 this month
|
}); // +$3 this month
|
||||||
// An old row (last year) must NOT count.
|
// An old row (year 2000) must NOT count toward the current month.
|
||||||
await prisma.ai_usage.create({
|
await prisma.ai_usage.create({
|
||||||
data: {
|
data: {
|
||||||
kind: KIND,
|
kind: KIND,
|
||||||
@@ -84,9 +90,11 @@ describe("ai.service cost + budget", () => {
|
|||||||
created_at: new Date("2000-01-01T00:00:00Z"),
|
created_at: new Date("2000-01-01T00:00:00Z"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const spend = await getMonthSpendUsd();
|
|
||||||
expect(spend).toBeGreaterThanOrEqual(3);
|
const after = await getMonthSpendUsd();
|
||||||
expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded
|
// Only the $3 current-month row moved the needle; the year-2000 $3 is
|
||||||
|
// excluded. The delta is exactly $3 (not $6).
|
||||||
|
expect(after - before).toBeCloseTo(3, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
||||||
@@ -94,7 +102,11 @@ describe("ai.service cost + budget", () => {
|
|||||||
// Under budget → null (allowed)
|
// Under budget → null (allowed)
|
||||||
expect(await assertBudgetAvailable()).toBeNull();
|
expect(await assertBudgetAvailable()).toBeNull();
|
||||||
// Push spend over budget for this month, then it must block with 402.
|
// Push spend over budget for this month, then it must block with 402.
|
||||||
await prisma.ai_usage.create({
|
// Tag with a unique marker so we can delete EXACTLY this row at the end of
|
||||||
|
// the test — that keeps the inflated spend from leaking into any other
|
||||||
|
// current-month test regardless of execution order (the beforeEach kind=KIND
|
||||||
|
// cleanup would catch it too, but cleaning in-test makes it order-proof).
|
||||||
|
const overBudget = await prisma.ai_usage.create({
|
||||||
data: {
|
data: {
|
||||||
kind: KIND,
|
kind: KIND,
|
||||||
model: "claude-sonnet-4-6",
|
model: "claude-sonnet-4-6",
|
||||||
@@ -104,9 +116,17 @@ describe("ai.service cost + budget", () => {
|
|||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
const blocked = await assertBudgetAvailable();
|
const blocked = await assertBudgetAvailable();
|
||||||
expect(blocked).not.toBeNull();
|
expect(blocked).not.toBeNull();
|
||||||
expect(blocked?.status).toBe(402);
|
expect(blocked?.status).toBe(402);
|
||||||
|
} finally {
|
||||||
|
// Remove the over-budget row immediately so it cannot inflate
|
||||||
|
// getMonthSpendUsd for any subsequently-running test.
|
||||||
|
await prisma.ai_usage
|
||||||
|
.delete({ where: { id: overBudget.id } })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isConfigured reflects the API key presence", () => {
|
it("isConfigured reflects the API key presence", () => {
|
||||||
|
|||||||
@@ -1,10 +1,72 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
import { verifyAccessToken, hashToken } from "../services/auth";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
// Prefix for fixtures so cleanup never touches real data.
|
||||||
|
const N = "auth_svc_test_";
|
||||||
|
|
||||||
|
let testUserId: number;
|
||||||
|
let testUsername: string;
|
||||||
|
let testRoleName: string | null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Clean up any leftover fixtures from a previous failed run.
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
|
||||||
|
// returns a full AuthData. We don't need a specific role — any active user
|
||||||
|
// works — but we record its role so we can assert roleName precisely.
|
||||||
|
const role = await prisma.roles.findFirst();
|
||||||
|
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||||
|
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user`,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
// DUMMY_HASH used elsewhere in the suite; we never verify a password
|
||||||
|
// here, only that the token's `sub` resolves to this user.
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "Auth",
|
||||||
|
last_name: "Service",
|
||||||
|
is_active: true,
|
||||||
|
role_id: role.id,
|
||||||
|
},
|
||||||
|
include: { roles: true },
|
||||||
|
});
|
||||||
|
testUserId = user.id;
|
||||||
|
testUsername = user.username;
|
||||||
|
testRoleName = user.roles?.name ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
describe("auth service", () => {
|
describe("auth service", () => {
|
||||||
describe("verifyAccessToken", () => {
|
describe("verifyAccessToken", () => {
|
||||||
|
it("returns AuthData for a valid token signed for a real user", async () => {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: testUserId, username: testUsername, role: testRoleName },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyAccessToken(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.userId).toBe(testUserId);
|
||||||
|
expect(result!.username).toBe(testUsername);
|
||||||
|
expect(result!.roleName).toBe(testRoleName);
|
||||||
|
// Permissions resolve to an array (admins get all, others get role perms).
|
||||||
|
expect(Array.isArray(result!.permissions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
||||||
const consoleSpy = vi
|
const consoleSpy = vi
|
||||||
.spyOn(console, "error")
|
.spyOn(console, "error")
|
||||||
@@ -35,6 +97,14 @@ describe("auth service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("hashToken", () => {
|
describe("hashToken", () => {
|
||||||
|
it("matches the known SHA-256 vector for a fixed input", () => {
|
||||||
|
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
|
||||||
|
// different but still 64-hex-deterministic hash) is caught.
|
||||||
|
expect(hashToken("hello")).toBe(
|
||||||
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("produces deterministic SHA-256 hex output", () => {
|
it("produces deterministic SHA-256 hex output", () => {
|
||||||
const t1 = hashToken("hello");
|
const t1 = hashToken("hello");
|
||||||
const t2 = hashToken("hello");
|
const t2 = hashToken("hello");
|
||||||
|
|||||||
@@ -1,12 +1,73 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
import { buildApp, extractCookie } from "./helpers";
|
import { buildApp, extractCookie } from "./helpers";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
|
||||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||||
|
|
||||||
|
// Fixture prefix so cleanup never touches real data.
|
||||||
|
const N = "auth_test_";
|
||||||
|
const TEST_USERNAME = `${N}user`;
|
||||||
|
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
|
||||||
|
|
||||||
|
let testUserId: number;
|
||||||
|
|
||||||
|
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
|
||||||
|
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
|
||||||
|
const cookies = res.headers["set-cookie"];
|
||||||
|
if (!cookies) return undefined;
|
||||||
|
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||||
|
return arr.find((c: string) => c.startsWith(`${name}=`));
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildApp();
|
app = await buildApp();
|
||||||
|
|
||||||
|
// Clean up any leftover fixture from a previous failed run, plus its tokens.
|
||||||
|
const leftovers = await prisma.users.findMany({
|
||||||
|
where: { username: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (leftovers.length) {
|
||||||
|
await prisma.refresh_tokens.deleteMany({
|
||||||
|
where: { user_id: { in: leftovers.map((u) => u.id) } },
|
||||||
|
});
|
||||||
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await prisma.roles.findFirst();
|
||||||
|
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||||
|
|
||||||
|
// Hash the known password with the SAME bcrypt cost the app uses so login's
|
||||||
|
// bcrypt.compare succeeds.
|
||||||
|
const passwordHash = await bcrypt.hash(
|
||||||
|
TEST_PASSWORD,
|
||||||
|
config.security.bcryptCost,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: TEST_USERNAME,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
first_name: "Auth",
|
||||||
|
last_name: "Test",
|
||||||
|
is_active: true,
|
||||||
|
totp_enabled: false,
|
||||||
|
role_id: role.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
testUserId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await prisma.refresh_tokens
|
||||||
|
.deleteMany({ where: { user_id: testUserId } })
|
||||||
|
.catch(() => {});
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 for a valid user with the wrong password", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/login",
|
||||||
|
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/login",
|
||||||
|
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(typeof body.data.access_token).toBe("string");
|
||||||
|
expect(body.data.access_token.length).toBeGreaterThan(0);
|
||||||
|
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
||||||
|
expect(body.data.user.userId).toBe(testUserId);
|
||||||
|
expect(body.data.user.username).toBe(TEST_USERNAME);
|
||||||
|
|
||||||
|
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
|
||||||
|
const cookie = extractCookie(res, "refresh_token");
|
||||||
|
expect(cookie).toBeTruthy();
|
||||||
|
const rawCookie = rawSetCookie(res, "refresh_token")!;
|
||||||
|
expect(rawCookie).toMatch(/HttpOnly/i);
|
||||||
|
expect(rawCookie).toMatch(/SameSite=Strict/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/admin/refresh", () => {
|
describe("POST /api/admin/refresh", () => {
|
||||||
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 for an invalid/unknown refresh token", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/refresh",
|
||||||
|
cookies: { refresh_token: "not-a-real-token" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
|
||||||
|
// First log in to obtain a valid refresh cookie.
|
||||||
|
const loginRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/login",
|
||||||
|
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||||
|
});
|
||||||
|
expect(loginRes.statusCode).toBe(200);
|
||||||
|
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
|
||||||
|
expect(originalRefresh).toBeTruthy();
|
||||||
|
|
||||||
|
const refreshRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/refresh",
|
||||||
|
cookies: { refresh_token: originalRefresh },
|
||||||
|
});
|
||||||
|
expect(refreshRes.statusCode).toBe(200);
|
||||||
|
const body = refreshRes.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(typeof body.data.access_token).toBe("string");
|
||||||
|
expect(body.data.user.userId).toBe(testUserId);
|
||||||
|
|
||||||
|
// The refresh token must be ROTATED: a new cookie value, different from
|
||||||
|
// the one we presented.
|
||||||
|
const rotated = extractCookie(refreshRes, "refresh_token");
|
||||||
|
expect(rotated).toBeTruthy();
|
||||||
|
expect(rotated).not.toBe(originalRefresh);
|
||||||
|
|
||||||
|
// Reuse-detection: presenting the now-consumed original token must fail.
|
||||||
|
const reuseRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/refresh",
|
||||||
|
cookies: { refresh_token: originalRefresh },
|
||||||
|
});
|
||||||
|
expect(reuseRes.statusCode).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/admin/logout", () => {
|
describe("POST /api/admin/logout", () => {
|
||||||
it("clears refresh token cookie", async () => {
|
it("clears the refresh token cookie", async () => {
|
||||||
|
// Log in so logout has a real token to delete.
|
||||||
|
const loginRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/login",
|
||||||
|
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
|
||||||
|
});
|
||||||
|
const refresh = extractCookie(loginRes, "refresh_token")!;
|
||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/admin/logout",
|
url: "/api/admin/logout",
|
||||||
|
cookies: { refresh_token: refresh },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBeLessThan(500);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// The response must actively clear the cookie — an expiry in the past
|
||||||
|
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
|
||||||
|
const raw = rawSetCookie(res, "refresh_token");
|
||||||
|
expect(raw).toBeTruthy();
|
||||||
|
const cleared =
|
||||||
|
/Max-Age=0/i.test(raw!) ||
|
||||||
|
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
|
||||||
|
/^refresh_token=;/.test(raw!);
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
|
||||||
|
// The deleted token must no longer be usable for refresh.
|
||||||
|
const reuseRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/refresh",
|
||||||
|
cookies: { refresh_token: refresh },
|
||||||
|
});
|
||||||
|
expect(reuseRes.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
import {
|
||||||
|
CreateCustomerSchema,
|
||||||
|
UpdateCustomerSchema,
|
||||||
|
} from "../schemas/customers.schema";
|
||||||
|
|
||||||
describe("UpdateCustomerSchema", () => {
|
describe("UpdateCustomerSchema", () => {
|
||||||
it("rejects empty name", () => {
|
it("rejects empty name", () => {
|
||||||
@@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => {
|
|||||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a name longer than 255 chars", () => {
|
||||||
|
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable address/identity fields set to null", () => {
|
||||||
|
const result = UpdateCustomerSchema.safeParse({
|
||||||
|
street: null,
|
||||||
|
city: null,
|
||||||
|
postal_code: null,
|
||||||
|
country: null,
|
||||||
|
company_id: null,
|
||||||
|
vat_id: null,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateCustomerSchema", () => {
|
||||||
|
it("requires a name", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a full valid payload with all optional fields", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
street: "Main St 1",
|
||||||
|
city: "Praha",
|
||||||
|
postal_code: "11000",
|
||||||
|
country: "CZ",
|
||||||
|
company_id: "12345678",
|
||||||
|
vat_id: "CZ12345678",
|
||||||
|
custom_fields: [{ label: "Note", value: "VIP" }],
|
||||||
|
customer_field_order: ["name", "street"],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts custom_fields as an array of arbitrary objects", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
custom_fields: [
|
||||||
|
{ label: "a", value: 1 },
|
||||||
|
{ nested: { deep: true } },
|
||||||
|
"raw string",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects custom_fields that is not an array", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
custom_fields: { not: "an array" },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects custom_fields longer than the 100-item cap", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects customer_field_order entries that are not strings", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
customer_field_order: ["name", 5],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an over-length nullable field (vat_id > 255)", () => {
|
||||||
|
const result = CreateCustomerSchema.safeParse({
|
||||||
|
name: "Acme Corp",
|
||||||
|
vat_id: "x".repeat(256),
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,3 +32,37 @@ describe("env validation", () => {
|
|||||||
expect(config.db.url).toBeTruthy();
|
expect(config.db.url).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("env validation — failure path", () => {
|
||||||
|
// The config singleton runs its validation at module-load time and is then
|
||||||
|
// cached by the module system, so we cannot re-import it with bad env to
|
||||||
|
// observe a throw without complex module-registry resetting. Instead we test
|
||||||
|
// the exact predicates config/env.ts uses to reject bad input — this is the
|
||||||
|
// logic that protects the boot, and the loaded config above proves the
|
||||||
|
// happy path. (Predicates kept in sync with src/config/env.ts.)
|
||||||
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
it("rejects a too-short / non-hex JWT_SECRET", () => {
|
||||||
|
expect(HEX64_RE.test("short")).toBe(false);
|
||||||
|
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
|
||||||
|
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
|
||||||
|
// The real config's secret must pass the very same check.
|
||||||
|
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an out-of-range or non-numeric PORT", () => {
|
||||||
|
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
|
||||||
|
expect(portValid(parseInt("0", 10))).toBe(false);
|
||||||
|
expect(portValid(parseInt("70000", 10))).toBe(false);
|
||||||
|
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
|
||||||
|
expect(portValid(config.port)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
|
||||||
|
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
|
||||||
|
expect(expiryValid(0)).toBe(false);
|
||||||
|
expect(expiryValid(-1)).toBe(false);
|
||||||
|
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
|
||||||
|
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { toCzk, getRate } from "../services/exchange-rates";
|
import {
|
||||||
|
toCzk,
|
||||||
|
getRate,
|
||||||
|
__resetRateCacheForTest,
|
||||||
|
} from "../services/exchange-rates";
|
||||||
|
|
||||||
// Mock global fetch
|
// Mock global fetch
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
@@ -8,6 +12,11 @@ global.fetch = mockFetch;
|
|||||||
describe("exchange-rates", () => {
|
describe("exchange-rates", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
// The rate cache is module-level and persists across tests. Without this
|
||||||
|
// reset, the first test to populate "today" would short-circuit every
|
||||||
|
// later mockFetch resolution (cache hit), making assertions order-dependent
|
||||||
|
// and asserting stale rates. Reset it so each test gets a clean fetch.
|
||||||
|
__resetRateCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toCzk", () => {
|
describe("toCzk", () => {
|
||||||
@@ -43,6 +52,21 @@ describe("exchange-rates", () => {
|
|||||||
const result = await toCzk(100, "EUR");
|
const result = await toCzk(100, "EUR");
|
||||||
expect(result).toBe(2500);
|
expect(result).toBe(2500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("honours amount != 1 (currency quoted per 100 units)", async () => {
|
||||||
|
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
|
||||||
|
// is for `amount` units, so the per-unit rate is rate/amount. Here the
|
||||||
|
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
|
||||||
|
// If the service ignored `amount` it would compute 100*250 = 25000.
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await toCzk(100, "HUF");
|
||||||
|
expect(result).toBe(250);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRate", () => {
|
describe("getRate", () => {
|
||||||
@@ -60,5 +84,17 @@ describe("exchange-rates", () => {
|
|||||||
});
|
});
|
||||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the per-unit rate for amount != 1", async () => {
|
||||||
|
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await getRate("HUF");
|
||||||
|
expect(result).toBe(2.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,31 @@ export async function buildApp() {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractCookie(response: any, name: string): string | undefined {
|
/**
|
||||||
|
* Minimal structural shape of the only thing extractCookie needs from a
|
||||||
|
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
|
||||||
|
* (from `app.inject`) and a plain supertest response without coupling to either.
|
||||||
|
*/
|
||||||
|
interface ResponseWithCookies {
|
||||||
|
headers: Record<string, string | string[] | number | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCookie(
|
||||||
|
response: ResponseWithCookies,
|
||||||
|
name: string,
|
||||||
|
): string | undefined {
|
||||||
const cookies = response.headers["set-cookie"];
|
const cookies = response.headers["set-cookie"];
|
||||||
if (!cookies) return undefined;
|
if (!cookies) return undefined;
|
||||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||||
for (const c of arr) {
|
for (const c of arr) {
|
||||||
|
if (typeof c !== "string") continue;
|
||||||
if (c.startsWith(`${name}=`)) {
|
if (c.startsWith(`${name}=`)) {
|
||||||
return c.split(";")[0].split("=")[1];
|
// The value is everything from the first "=" up to the first ";".
|
||||||
|
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
|
||||||
|
// padding) is not truncated — `split("=")[1]` would drop everything after
|
||||||
|
// the second "=".
|
||||||
|
const pair = c.split(";")[0];
|
||||||
|
return pair.slice(pair.indexOf("=") + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||||
|
|
||||||
|
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||||
|
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||||
|
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
|
||||||
|
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
|
||||||
|
// for `Number(decimal)`) is actually caught.
|
||||||
|
|
||||||
|
const dec = (n: number | string) => new Prisma.Decimal(n);
|
||||||
|
|
||||||
|
// Helper so each test reads as a small declarative table of lines.
|
||||||
|
function buildInvoice(opts: {
|
||||||
|
applyVat: boolean;
|
||||||
|
vatRate: number | null;
|
||||||
|
currency?: string;
|
||||||
|
items: Array<{
|
||||||
|
quantity: number | null;
|
||||||
|
unit_price: number | null;
|
||||||
|
vat_rate: number | null;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
apply_vat: opts.applyVat,
|
||||||
|
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
|
||||||
|
currency: opts.currency ?? "CZK",
|
||||||
|
invoice_items: opts.items.map((i) => ({
|
||||||
|
quantity: i.quantity === null ? null : dec(i.quantity),
|
||||||
|
unit_price: i.unit_price === null ? null : dec(i.unit_price),
|
||||||
|
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("invoiceTotalWithVat", () => {
|
describe("invoiceTotalWithVat", () => {
|
||||||
it("calculates subtotal without VAT when apply_vat is false", () => {
|
it("calculates subtotal without VAT when apply_vat is false", () => {
|
||||||
const inv = {
|
const inv = buildInvoice({
|
||||||
apply_vat: false,
|
applyVat: false,
|
||||||
vat_rate: { toNumber: () => 21 },
|
vatRate: 21,
|
||||||
currency: "CZK",
|
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
|
||||||
invoice_items: [
|
});
|
||||||
{
|
|
||||||
quantity: { toNumber: () => 2 },
|
|
||||||
unit_price: { toNumber: () => 100 },
|
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,32 +49,72 @@ describe("invoiceTotalWithVat", () => {
|
|||||||
// Total VAT = 7.00 * 3 = 21.00
|
// Total VAT = 7.00 * 3 = 21.00
|
||||||
// Subtotal = 33.33 * 3 = 99.99
|
// Subtotal = 33.33 * 3 = 99.99
|
||||||
// Total = 99.99 + 21.00 = 120.99
|
// Total = 99.99 + 21.00 = 120.99
|
||||||
const inv = {
|
const inv = buildInvoice({
|
||||||
apply_vat: true,
|
applyVat: true,
|
||||||
vat_rate: { toNumber: () => 21 },
|
vatRate: 21,
|
||||||
currency: "CZK",
|
items: Array.from({ length: 3 }, () => ({
|
||||||
invoice_items: Array.from({ length: 3 }, () => ({
|
quantity: 1,
|
||||||
quantity: { toNumber: () => 1 },
|
unit_price: 33.33,
|
||||||
unit_price: { toNumber: () => 33.33 },
|
vat_rate: 21,
|
||||||
vat_rate: { toNumber: () => 21 },
|
|
||||||
})),
|
})),
|
||||||
};
|
});
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles null quantity and unit_price gracefully", () => {
|
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
|
||||||
const inv = {
|
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
|
||||||
apply_vat: true,
|
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
|
||||||
vat_rate: { toNumber: () => 21 },
|
// line proves the null line contributes nothing while the rest still totals.
|
||||||
currency: "CZK",
|
const inv = buildInvoice({
|
||||||
invoice_items: [
|
applyVat: true,
|
||||||
{
|
vatRate: 21,
|
||||||
quantity: { toNumber: () => 0 },
|
items: [
|
||||||
unit_price: { toNumber: () => 0 },
|
{ quantity: null, unit_price: null, vat_rate: 21 },
|
||||||
vat_rate: { toNumber: () => 21 },
|
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
expect(invoiceTotalWithVat(inv)).toBe(121);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when every line is null", () => {
|
||||||
|
const inv = buildInvoice({
|
||||||
|
applyVat: true,
|
||||||
|
vatRate: 21,
|
||||||
|
items: [{ quantity: null, unit_price: null, vat_rate: null }],
|
||||||
|
});
|
||||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
|
||||||
|
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
|
||||||
|
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
|
||||||
|
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
|
||||||
|
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
|
||||||
|
// Subtotal = 350, VAT = 63, Total = 413
|
||||||
|
const inv = buildInvoice({
|
||||||
|
applyVat: true,
|
||||||
|
vatRate: 15,
|
||||||
|
items: [
|
||||||
|
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||||
|
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||||
|
{ quantity: 1, unit_price: 100, vat_rate: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(invoiceTotalWithVat(inv)).toBe(413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a negative (discount) line correctly", () => {
|
||||||
|
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
|
||||||
|
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
|
||||||
|
// Subtotal = 800, VAT = 168, Total = 968
|
||||||
|
const inv = buildInvoice({
|
||||||
|
applyVat: true,
|
||||||
|
vatRate: 21,
|
||||||
|
items: [
|
||||||
|
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
|
{ quantity: 1, unit_price: -200, vat_rate: 21 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ const baseOrder = {
|
|||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
|
||||||
|
* passed the test when a create failed) while still narrowing the union via the
|
||||||
|
* `in` operator so the success-branch fields stay typed.
|
||||||
|
*/
|
||||||
|
function failResult(res: unknown): never {
|
||||||
|
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||||
|
}
|
||||||
|
|
||||||
describe("createOrder (manual, optional linked project)", () => {
|
describe("createOrder (manual, optional linked project)", () => {
|
||||||
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||||
const res = await createOrder({
|
const res = await createOrder({
|
||||||
@@ -32,9 +41,8 @@ describe("createOrder (manual, optional linked project)", () => {
|
|||||||
scope_title: "Ruční zakázka",
|
scope_title: "Ruční zakázka",
|
||||||
create_project: true,
|
create_project: true,
|
||||||
});
|
});
|
||||||
expect("id" in res).toBe(true);
|
if (!("id" in res)) failResult(res);
|
||||||
if (!("id" in res)) return;
|
createdOrderIds.push(res.id!);
|
||||||
createdOrderIds.push(res.id);
|
|
||||||
expect(res.project_id).toBeTruthy();
|
expect(res.project_id).toBeTruthy();
|
||||||
const project = await prisma.projects.findUnique({
|
const project = await prisma.projects.findUnique({
|
||||||
where: { id: res.project_id! },
|
where: { id: res.project_id! },
|
||||||
@@ -46,9 +54,8 @@ describe("createOrder (manual, optional linked project)", () => {
|
|||||||
|
|
||||||
it("creates only the order when create_project=false", async () => {
|
it("creates only the order when create_project=false", async () => {
|
||||||
const res = await createOrder({ ...baseOrder, create_project: false });
|
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||||
expect("id" in res).toBe(true);
|
if (!("id" in res)) failResult(res);
|
||||||
if (!("id" in res)) return;
|
createdOrderIds.push(res.id!);
|
||||||
createdOrderIds.push(res.id);
|
|
||||||
expect(res.project_id).toBeUndefined();
|
expect(res.project_id).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -56,19 +63,25 @@ describe("createOrder (manual, optional linked project)", () => {
|
|||||||
describe("shared pool coherence (tracking)", () => {
|
describe("shared pool coherence (tracking)", () => {
|
||||||
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||||
if ("id" in o1) {
|
if (!("id" in o1)) failResult(o1);
|
||||||
createdOrderIds.push(o1.id);
|
createdOrderIds.push(o1.id!);
|
||||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||||
}
|
|
||||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
|
||||||
if ("project_number" in p) createdProjectIds.push(p.id);
|
|
||||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
|
||||||
if ("id" in o2) createdOrderIds.push(o2.id);
|
|
||||||
|
|
||||||
const n1 = "id" in o1 ? o1.order_number : null;
|
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||||
const np = "project_number" in p ? p.project_number : null;
|
if (!("project_number" in p)) failResult(p);
|
||||||
const n2 = "id" in o2 ? o2.order_number : null;
|
createdProjectIds.push(p.id);
|
||||||
expect(new Set([n1, np, n2]).size).toBe(3);
|
|
||||||
|
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||||
|
if (!("id" in o2)) failResult(o2);
|
||||||
|
createdOrderIds.push(o2.id!);
|
||||||
|
|
||||||
|
// All three numbers must be present (non-null) AND distinct.
|
||||||
|
expect(o1.order_number).toBeTruthy();
|
||||||
|
expect(p.project_number).toBeTruthy();
|
||||||
|
expect(o2.order_number).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
new Set([o1.order_number, p.project_number, o2.order_number]).size,
|
||||||
|
).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,8 +91,7 @@ describe("createProject (manual, standalone)", () => {
|
|||||||
name: "Test projekt",
|
name: "Test projekt",
|
||||||
status: "aktivni",
|
status: "aktivni",
|
||||||
});
|
});
|
||||||
expect("project_number" in result).toBe(true);
|
if (!("project_number" in result)) failResult(result);
|
||||||
if (!("project_number" in result)) return;
|
|
||||||
createdProjectIds.push(result.id);
|
createdProjectIds.push(result.id);
|
||||||
expect(typeof result.project_number).toBe("string");
|
expect(typeof result.project_number).toBe("string");
|
||||||
expect(result.project_number!.length).toBeGreaterThan(0);
|
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||||
@@ -112,9 +124,8 @@ describe("createOrder with price line item + attachment", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect("id" in res).toBe(true);
|
if (!("id" in res)) failResult(res);
|
||||||
if (!("id" in res)) return;
|
createdOrderIds.push(res.id!);
|
||||||
createdOrderIds.push(res.id);
|
|
||||||
const items = await prisma.order_items.findMany({
|
const items = await prisma.order_items.findMany({
|
||||||
where: { order_id: res.id },
|
where: { order_id: res.id },
|
||||||
});
|
});
|
||||||
@@ -122,21 +133,25 @@ describe("createOrder with price line item + attachment", () => {
|
|||||||
expect(Number(items[0].unit_price)).toBe(12345);
|
expect(Number(items[0].unit_price)).toBe(12345);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores an uploaded PO attachment", async () => {
|
it("stores an uploaded PO attachment with the exact bytes", async () => {
|
||||||
const buf = Buffer.from("%PDF-1.4 fake");
|
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
|
||||||
const res = await createOrder(
|
const res = await createOrder(
|
||||||
{ ...baseOrder, create_project: false },
|
{ ...baseOrder, create_project: false },
|
||||||
buf,
|
buf,
|
||||||
"po.pdf",
|
"po.pdf",
|
||||||
);
|
);
|
||||||
expect("id" in res).toBe(true);
|
if (!("id" in res)) failResult(res);
|
||||||
if (!("id" in res)) return;
|
createdOrderIds.push(res.id!);
|
||||||
createdOrderIds.push(res.id);
|
|
||||||
const order = await prisma.orders.findUnique({
|
const order = await prisma.orders.findUnique({
|
||||||
where: { id: res.id },
|
where: { id: res.id },
|
||||||
select: { attachment_name: true, attachment_data: true },
|
select: { attachment_name: true, attachment_data: true },
|
||||||
});
|
});
|
||||||
expect(order?.attachment_name).toBe("po.pdf");
|
expect(order?.attachment_name).toBe("po.pdf");
|
||||||
|
// Verify the stored content/size, not just truthiness: round-trip the
|
||||||
|
// bytes and compare length + value against what we uploaded.
|
||||||
expect(order?.attachment_data).toBeTruthy();
|
expect(order?.attachment_data).toBeTruthy();
|
||||||
|
const stored = Buffer.from(order!.attachment_data!);
|
||||||
|
expect(stored.length).toBe(buf.length);
|
||||||
|
expect(stored.equals(buf)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
import { NasFileManager } from "../services/nas-file-manager";
|
import { NasFileManager } from "../services/nas-file-manager";
|
||||||
|
|
||||||
describe("NasFileManager path traversal", () => {
|
describe("NasFileManager path traversal", () => {
|
||||||
@@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positive-path coverage: a legitimate (non-traversal) path inside a real
|
||||||
|
* project folder is ACCEPTED — it passes resolveProjectPath validation and
|
||||||
|
* reaches the underlying fs op (success → null). Without this, a
|
||||||
|
* "reject-everything" regression in the traversal guard would still pass every
|
||||||
|
* rejection test above. Uses the constructor basePath seam + a temp dir so it
|
||||||
|
* is deterministic even where the real NAS (Z:) is not mounted.
|
||||||
|
*/
|
||||||
|
describe("NasFileManager accepts legitimate paths", () => {
|
||||||
|
let tmpBase: string;
|
||||||
|
let nas: NasFileManager;
|
||||||
|
const NUM = "29990001";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
|
||||||
|
nas = new NasFileManager(tmpBase);
|
||||||
|
// Create a real project folder <number>_<name> so findProjectFolder resolves.
|
||||||
|
nas.createProjectFolder(NUM, "Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
|
||||||
|
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||||
|
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
|
||||||
|
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
|
||||||
|
|
||||||
|
const result = await nas.deleteItem(NUM, "doc.pdf");
|
||||||
|
// null = success: the path was accepted and the file actually removed.
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
|
||||||
|
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||||
|
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||||
|
|
||||||
|
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
|
||||||
|
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||||
|
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
|
||||||
|
|
||||||
|
const result = await nas.deleteItem(NUM, "../keep.pdf");
|
||||||
|
expect(result).toContain("Neplatná cesta");
|
||||||
|
// The guard rejected the path before any fs op — nothing was removed.
|
||||||
|
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
|
||||||
|
// With a real project folder, the source `from.pdf` resolves cleanly, so
|
||||||
|
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
|
||||||
|
// the source must stay put (no move outside the project folder).
|
||||||
|
const folder = path.join(tmpBase, `${NUM}_Test`);
|
||||||
|
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
|
||||||
|
|
||||||
|
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
|
||||||
|
expect(result).toContain("Neplatná cesta");
|
||||||
|
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
|
||||||
|
// Nothing escaped the project folder.
|
||||||
|
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,49 @@ import {
|
|||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
const YEAR = new Date().getFullYear();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert two consecutive document numbers (a) keep an identical format / year
|
||||||
|
* prefix and (b) increment by exactly one — without hard-coding which
|
||||||
|
* `company_settings` pattern is configured.
|
||||||
|
*
|
||||||
|
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
|
||||||
|
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
|
||||||
|
* of the two strings (the unchanged format/year portion) and parse the
|
||||||
|
* remaining differing suffixes as integers — these are the rendered sequences.
|
||||||
|
*/
|
||||||
|
function assertStrictIncrement(num1: string, num2: string) {
|
||||||
|
expect(num1).not.toBe(num2);
|
||||||
|
expect(num2.length).toBe(num1.length); // same width ⇒ same format
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < num1.length && num1[i] === num2[i]) i++;
|
||||||
|
const commonPrefix = num1.slice(0, i);
|
||||||
|
// The format/year portion is shared and non-empty (the numbers don't differ
|
||||||
|
// from the very first character).
|
||||||
|
expect(commonPrefix.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const seq1 = Number(num1.slice(i));
|
||||||
|
const seq2 = Number(num2.slice(i));
|
||||||
|
expect(Number.isNaN(seq1)).toBe(false);
|
||||||
|
expect(Number.isNaN(seq2)).toBe(false);
|
||||||
|
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the (type, year) sequence row to a high `last_number` so the next
|
||||||
|
* generated numbers land far above any real order/quotation in the test DB —
|
||||||
|
* the generator's skip-taken retry then never fires and the strict +1
|
||||||
|
* assertion is deterministic.
|
||||||
|
*/
|
||||||
|
async function seedSequence(type: string, lastNumber: number) {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type } });
|
||||||
|
await prisma.number_sequences.create({
|
||||||
|
data: { type, year: YEAR, last_number: lastNumber },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("generateSharedNumber", () => {
|
describe("generateSharedNumber", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
@@ -24,10 +67,13 @@ describe("generateSharedNumber", () => {
|
|||||||
expect(num.length).toBeGreaterThan(0);
|
expect(num.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("increments on consecutive calls", async () => {
|
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||||
|
// Seed high so neither generated number collides with a real order/project
|
||||||
|
// → the skip-taken retry never fires and the increment is strictly +1.
|
||||||
|
await seedSequence("shared", 900000);
|
||||||
const num1 = await generateSharedNumber();
|
const num1 = await generateSharedNumber();
|
||||||
const num2 = await generateSharedNumber();
|
const num2 = await generateSharedNumber();
|
||||||
expect(num1).not.toBe(num2);
|
assertStrictIncrement(num1, num2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,10 +92,11 @@ describe("generateOfferNumber", () => {
|
|||||||
expect(num.length).toBeGreaterThan(0);
|
expect(num.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("increments on consecutive calls", async () => {
|
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||||
|
await seedSequence("offer", 900000);
|
||||||
const num1 = await generateOfferNumber();
|
const num1 = await generateOfferNumber();
|
||||||
const num2 = await generateOfferNumber();
|
const num2 = await generateOfferNumber();
|
||||||
expect(num1).not.toBe(num2);
|
assertStrictIncrement(num1, num2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,9 +124,17 @@ describe("previewSharedNumber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||||
// Fresh counter → preview points at the first sequence number. With the
|
// Seed the counter HIGH so the preview lands far above any real
|
||||||
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
// order/project in the test DB. Without this, a real row could already
|
||||||
|
// hold the first sequence number — making the create collide on the unique
|
||||||
|
// column, or making `second !== first` pass for the wrong reason (because
|
||||||
|
// the counter happened to already point past `first`).
|
||||||
|
await seedSequence("shared", 900000);
|
||||||
const first = await previewSharedNumber();
|
const first = await previewSharedNumber();
|
||||||
|
// Preconditions: the seeded preview really is free (so the create below is
|
||||||
|
// safe) and is exactly what the generator would assign next.
|
||||||
|
expect(await isSharedNumberTaken(first)).toBe(false);
|
||||||
|
|
||||||
const project = await prisma.projects.create({
|
const project = await prisma.projects.create({
|
||||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||||
});
|
});
|
||||||
@@ -117,9 +172,14 @@ describe("previewOfferNumber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||||
// Fresh counter → preview points at the first sequence number; with the
|
// Seed HIGH so the preview lands above any real quotation in the test DB
|
||||||
// skip-taken logic it's guaranteed free, so we can claim it.
|
// (see the shared-number test for the rationale): the create can't collide
|
||||||
|
// and the skip-advance is genuinely exercised rather than passing because a
|
||||||
|
// real row already advanced the preview.
|
||||||
|
await seedSequence("offer", 900000);
|
||||||
const first = await previewOfferNumber();
|
const first = await previewOfferNumber();
|
||||||
|
expect(await isOfferNumberTaken(first)).toBe(false);
|
||||||
|
|
||||||
const quotation = await prisma.quotations.create({
|
const quotation = await prisma.quotations.create({
|
||||||
data: { quotation_number: first },
|
data: { quotation_number: first },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import prisma from "../config/database";
|
|||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import { securityHeaders } from "../middleware/security";
|
import { securityHeaders } from "../middleware/security";
|
||||||
import planRoutes from "../routes/admin/plan";
|
import planRoutes from "../routes/admin/plan";
|
||||||
|
import { localDateStr } from "../utils/date";
|
||||||
import {
|
import {
|
||||||
resolveCell,
|
resolveCell,
|
||||||
resolveGrid,
|
resolveGrid,
|
||||||
@@ -31,6 +32,14 @@ import {
|
|||||||
const N = "wh_plan_";
|
const N = "wh_plan_";
|
||||||
|
|
||||||
let adminUserId: number;
|
let adminUserId: number;
|
||||||
|
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
|
||||||
|
// tests (and as the second employee in the multi-user bulk test). Creating our
|
||||||
|
// own user — instead of grabbing "the second user in the DB" — guarantees it is
|
||||||
|
// never accidentally the admin (which would make `toEqual([])` pass for the
|
||||||
|
// wrong reason on a single-user DB), and that it is genuinely distinct so the
|
||||||
|
// 2-employee aggregate assertion can't collapse to 1.
|
||||||
|
let scopeUserId: number;
|
||||||
|
let scopeRoleId: number;
|
||||||
let noPermUserId: number;
|
let noPermUserId: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -43,11 +52,44 @@ beforeAll(async () => {
|
|||||||
if (!admin) throw new Error("Test setup: admin user not found");
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
adminUserId = admin.id;
|
adminUserId = admin.id;
|
||||||
|
|
||||||
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
// Dedicated non-admin user + role for scoping/multi-user tests.
|
||||||
// for create tests. Just pick the second user.
|
const stamp = Date.now();
|
||||||
const allUsers = await prisma.users.findMany({ take: 2 });
|
const role = await prisma.roles.create({
|
||||||
noPermUserId =
|
data: {
|
||||||
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
name: `scope_plan_${stamp}`,
|
||||||
|
display_name: "Plan Scope Test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scopeRoleId = role.id;
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `scope_plan_${stamp}`,
|
||||||
|
first_name: "Scope",
|
||||||
|
last_name: "User",
|
||||||
|
email: `scope_plan_${stamp}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scopeUserId = user.id;
|
||||||
|
if (scopeUserId === adminUserId)
|
||||||
|
throw new Error("scope user must be distinct from admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
|
||||||
|
// leftover FK (none expected — its rows are note-prefixed and removed) can't
|
||||||
|
// mask real failures.
|
||||||
|
if (scopeUserId)
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { id: scopeUserId } })
|
||||||
|
.catch(() => {});
|
||||||
|
if (scopeRoleId)
|
||||||
|
await prisma.roles
|
||||||
|
.deleteMany({ where: { id: scopeRoleId } })
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
|
|||||||
const result = assertNotPastDate("2000-01-01", true);
|
const result = assertNotPastDate("2000-01-01", true);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
|
||||||
|
// Deterministic without mocking the clock: compute "today" exactly the way
|
||||||
|
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
|
||||||
|
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
|
||||||
|
// would wrongly reject editing today's plan.
|
||||||
|
const today = localDateStr(new Date());
|
||||||
|
expect(assertNotPastDate(today, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects YESTERDAY (local Prague date) as a past date", () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 1); // local-time yesterday
|
||||||
|
const yesterday = localDateStr(d);
|
||||||
|
const result = assertNotPastDate(yesterday, false);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.status).toBe(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => {
|
|||||||
);
|
);
|
||||||
expect("data" in updated).toBe(true);
|
expect("data" in updated).toBe(true);
|
||||||
if ("data" in updated) {
|
if ("data" in updated) {
|
||||||
expect(updated.data.note).toBe(`${N}updated`);
|
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => {
|
|||||||
);
|
);
|
||||||
expect("data" in updated).toBe(true);
|
expect("data" in updated).toBe(true);
|
||||||
if ("data" in updated) {
|
if ("data" in updated) {
|
||||||
expect(updated.data.note).toBe(`${N}updated`);
|
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
|
|||||||
);
|
);
|
||||||
const rows = await listEntries(
|
const rows = await listEntries(
|
||||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||||
noPermUserId,
|
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||||
false, // not admin
|
false, // not admin
|
||||||
);
|
);
|
||||||
|
// A non-admin actor querying ANOTHER user's data is scoped to its own
|
||||||
|
// user_id, so it sees none of the admin's entries. This can only be
|
||||||
|
// [] for the right reason because scopeUserId !== adminUserId (asserted
|
||||||
|
// in beforeAll).
|
||||||
expect(rows).toEqual([]);
|
expect(rows).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
|||||||
);
|
);
|
||||||
const rows = await listOverrides(
|
const rows = await listOverrides(
|
||||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||||
noPermUserId,
|
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(rows).toEqual([]);
|
expect(rows).toEqual([]);
|
||||||
@@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("aggregates across multiple employees", async () => {
|
it("aggregates across multiple employees", async () => {
|
||||||
|
// Two genuinely distinct employees (admin + the dedicated scope user) so
|
||||||
|
// `users` can't collapse to 1 if the DB happened to have a single user.
|
||||||
const res = await bulkCreateEntries(
|
const res = await bulkCreateEntries(
|
||||||
{
|
{
|
||||||
user_ids: [adminUserId, noPermUserId],
|
user_ids: [adminUserId, scopeUserId],
|
||||||
date_from: "2096-08-03", // Friday
|
date_from: "2096-08-03", // Friday
|
||||||
date_to: "2096-08-03",
|
date_to: "2096-08-03",
|
||||||
include_weekends: true,
|
include_weekends: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, afterAll } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import {
|
import {
|
||||||
listPlanCategories,
|
listPlanCategories,
|
||||||
@@ -12,10 +12,28 @@ import {
|
|||||||
|
|
||||||
const created: number[] = [];
|
const created: number[] = [];
|
||||||
|
|
||||||
|
// Every test category's slug starts with "test_" AND contains "_zz" (labels all
|
||||||
|
// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids —
|
||||||
|
// means a previous crashed run can't leave a `test_kategorie_zz` row behind that
|
||||||
|
// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow
|
||||||
|
// pattern avoids touching real seed categories (work/other/…).
|
||||||
|
async function cleanupByPrefix() {
|
||||||
|
await prisma.plan_categories.deleteMany({
|
||||||
|
where: { key: { startsWith: "test_", contains: "_zz" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupByPrefix();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (created.length) {
|
if (created.length) {
|
||||||
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
||||||
}
|
}
|
||||||
|
// Belt-and-suspenders: also remove anything matching the slug prefix in case
|
||||||
|
// a test threw before pushing its id into `created`.
|
||||||
|
await cleanupByPrefix();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +42,13 @@ describe("slugifyCategory", () => {
|
|||||||
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
||||||
expect(slugifyCategory("Příprava")).toBe("priprava");
|
expect(slugifyCategory("Příprava")).toBe("priprava");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collapses an all-symbol label to an empty slug", () => {
|
||||||
|
// No alphanumerics survive → empty string. createPlanCategory's uniqueKey
|
||||||
|
// then falls back to "kategorie" so the row is still creatable.
|
||||||
|
expect(slugifyCategory("***")).toBe("");
|
||||||
|
expect(slugifyCategory(" / ")).toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("plan category service", () => {
|
describe("plan category service", () => {
|
||||||
|
|||||||
@@ -23,4 +23,29 @@ describe("vatFromGross (VAT contained in a gross total)", () => {
|
|||||||
it("returns 0 for a zero amount", () => {
|
it("returns 0 for a zero amount", () => {
|
||||||
expect(vatFromGross(0, 21)).toBe(0);
|
expect(vatFromGross(0, 21)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => {
|
||||||
|
// 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent
|
||||||
|
// up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of-
|
||||||
|
// round regression (which would yield 20.04) fails.
|
||||||
|
expect(vatFromGross(115.5, 21)).toBe(20.05);
|
||||||
|
// 95.70 @ 21% -> raw 16.609090... rounds up to 16.61.
|
||||||
|
expect(vatFromGross(95.7, 21)).toBe(16.61);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds the cent DOWN when below the half-cent", () => {
|
||||||
|
// 100.40 @ 21% -> raw 17.424793... rounds down to 17.42.
|
||||||
|
expect(vatFromGross(100.4, 21)).toBe(17.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the result to the exact stored 2-dp value (not just close)", () => {
|
||||||
|
// The DB column is Decimal(_, 2). vatFromGross must already return a value
|
||||||
|
// with no third-decimal float drift. Strict equality + a 2-dp self-check,
|
||||||
|
// NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail.
|
||||||
|
const v = vatFromGross(1210, 21);
|
||||||
|
expect(v).toBe(210); // exact, not ~210
|
||||||
|
expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean
|
||||||
|
// The real-world figure too: pinned exactly, replacing the toBeCloseTo above.
|
||||||
|
expect(vatFromGross(22542.91, 21)).toBe(3912.41);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
import { CreateOrderSchema } from "../schemas/orders.schema";
|
||||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||||
|
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||||
|
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
|
||||||
|
import {
|
||||||
|
ReceiptItemSchema,
|
||||||
|
CreateSupplierSchema,
|
||||||
|
} from "../schemas/warehouse.schema";
|
||||||
|
import { UpdateCompanySettingsSchema } from "../schemas/settings.schema";
|
||||||
|
|
||||||
describe("Zod NaN rejection", () => {
|
// These schemas now build on the shared form-coercion helpers in
|
||||||
|
// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …).
|
||||||
|
// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers
|
||||||
|
// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN
|
||||||
|
// string. The original suite only asserted (b); these tests pin (a) as well.
|
||||||
|
|
||||||
|
describe("Zod form coercion + NaN rejection", () => {
|
||||||
describe("CreateOrderSchema", () => {
|
describe("CreateOrderSchema", () => {
|
||||||
it("rejects NaN string in quantity", () => {
|
it("rejects NaN string in quantity", () => {
|
||||||
const result = CreateOrderSchema.safeParse({
|
const result = CreateOrderSchema.safeParse({
|
||||||
@@ -27,6 +40,21 @@ describe("Zod NaN rejection", () => {
|
|||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces a valid numeric STRING quantity/unit_price to a number", () => {
|
||||||
|
const result = CreateOrderSchema.safeParse({
|
||||||
|
customer_id: 1,
|
||||||
|
items: [{ quantity: "2", unit_price: "100.5" }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const item = result.data.items![0];
|
||||||
|
expect(item.quantity).toBe(2);
|
||||||
|
expect(typeof item.quantity).toBe("number");
|
||||||
|
expect(item.unit_price).toBe(100.5);
|
||||||
|
expect(typeof item.unit_price).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CreateQuotationSchema", () => {
|
describe("CreateQuotationSchema", () => {
|
||||||
@@ -46,6 +74,18 @@ describe("Zod NaN rejection", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces a valid numeric STRING vat_rate to a number", () => {
|
||||||
|
const result = CreateQuotationSchema.safeParse({
|
||||||
|
customer_id: 1,
|
||||||
|
vat_rate: "21",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.vat_rate).toBe(21);
|
||||||
|
expect(typeof result.data.vat_rate).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects NaN string in item quantity", () => {
|
it("rejects NaN string in item quantity", () => {
|
||||||
const result = CreateQuotationSchema.safeParse({
|
const result = CreateQuotationSchema.safeParse({
|
||||||
customer_id: 1,
|
customer_id: 1,
|
||||||
@@ -53,5 +93,215 @@ describe("Zod NaN rejection", () => {
|
|||||||
});
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces a valid numeric STRING item quantity/unit_price", () => {
|
||||||
|
const result = CreateQuotationSchema.safeParse({
|
||||||
|
customer_id: 1,
|
||||||
|
items: [{ quantity: "3", unit_price: "250" }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const item = result.data.items![0];
|
||||||
|
expect(item.quantity).toBe(3);
|
||||||
|
expect(item.unit_price).toBe(250);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The remaining schemas were the ones flagged as accepting NaN before the
|
||||||
|
// shared helpers landed. Assert both the coercion and the rejection paths.
|
||||||
|
|
||||||
|
describe("CreateTripSchema", () => {
|
||||||
|
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
||||||
|
const result = CreateTripSchema.safeParse({
|
||||||
|
vehicle_id: "5",
|
||||||
|
trip_date: "2026-01-15",
|
||||||
|
start_km: "100",
|
||||||
|
end_km: "150.5",
|
||||||
|
route_from: "Praha",
|
||||||
|
route_to: "Brno",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.vehicle_id).toBe(5);
|
||||||
|
expect(result.data.start_km).toBe(100);
|
||||||
|
expect(result.data.end_km).toBe(150.5);
|
||||||
|
expect(typeof result.data.start_km).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN string in start_km", () => {
|
||||||
|
const result = CreateTripSchema.safeParse({
|
||||||
|
vehicle_id: 5,
|
||||||
|
trip_date: "2026-01-15",
|
||||||
|
start_km: "not-a-number",
|
||||||
|
end_km: 150,
|
||||||
|
route_from: "Praha",
|
||||||
|
route_to: "Brno",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN / non-positive string in vehicle_id (FK)", () => {
|
||||||
|
const result = CreateTripSchema.safeParse({
|
||||||
|
vehicle_id: "abc",
|
||||||
|
trip_date: "2026-01-15",
|
||||||
|
start_km: 100,
|
||||||
|
end_km: 150,
|
||||||
|
route_from: "Praha",
|
||||||
|
route_to: "Brno",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateReceivedInvoiceSchema", () => {
|
||||||
|
it("coerces numeric STRING month / year / amount to numbers", () => {
|
||||||
|
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||||
|
supplier_name: "ACME s.r.o.",
|
||||||
|
month: "6",
|
||||||
|
year: "2026",
|
||||||
|
amount: "1210.50",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.month).toBe(6);
|
||||||
|
expect(result.data.year).toBe(2026);
|
||||||
|
expect(result.data.amount).toBe(1210.5);
|
||||||
|
expect(typeof result.data.amount).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN string in amount", () => {
|
||||||
|
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||||
|
supplier_name: "ACME s.r.o.",
|
||||||
|
month: 6,
|
||||||
|
year: 2026,
|
||||||
|
amount: "not-money",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an out-of-range month (numberInRange 1-12)", () => {
|
||||||
|
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||||
|
supplier_name: "ACME s.r.o.",
|
||||||
|
month: 13,
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN string in month", () => {
|
||||||
|
const result = CreateReceivedInvoiceSchema.safeParse({
|
||||||
|
supplier_name: "ACME s.r.o.",
|
||||||
|
month: "bad",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warehouse ReceiptItemSchema", () => {
|
||||||
|
it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => {
|
||||||
|
const result = ReceiptItemSchema.safeParse({
|
||||||
|
item_id: "7",
|
||||||
|
quantity: "12.5",
|
||||||
|
unit_price: "99",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.item_id).toBe(7);
|
||||||
|
expect(result.data.quantity).toBe(12.5);
|
||||||
|
expect(result.data.unit_price).toBe(99);
|
||||||
|
expect(typeof result.data.quantity).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN string in quantity", () => {
|
||||||
|
const result = ReceiptItemSchema.safeParse({
|
||||||
|
item_id: 7,
|
||||||
|
quantity: "lots",
|
||||||
|
unit_price: 99,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive quantity (positiveNumberFromForm)", () => {
|
||||||
|
const result = ReceiptItemSchema.safeParse({
|
||||||
|
item_id: 7,
|
||||||
|
quantity: 0,
|
||||||
|
unit_price: 99,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NaN / non-positive string in item_id (FK)", () => {
|
||||||
|
const result = ReceiptItemSchema.safeParse({
|
||||||
|
item_id: "abc",
|
||||||
|
quantity: 5,
|
||||||
|
unit_price: 99,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression tests for the 2026-06-09 audit fix pass: the schema hardening
|
||||||
|
// must NOT reject input the app legitimately sends.
|
||||||
|
describe("schema hardening — does not reject previously-valid input", () => {
|
||||||
|
it("settings: optional email fields accept an empty string (un-filled)", () => {
|
||||||
|
const r = UpdateCompanySettingsSchema.safeParse({
|
||||||
|
invoice_alert_email: "",
|
||||||
|
leave_notify_email: "",
|
||||||
|
smtp_from: "",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settings: a valid email is accepted, a malformed one rejected", () => {
|
||||||
|
expect(
|
||||||
|
UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
|
||||||
|
expect(
|
||||||
|
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
|
||||||
|
.success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||||
|
const base = {
|
||||||
|
vehicle_id: 1,
|
||||||
|
start_km: 100,
|
||||||
|
end_km: 120,
|
||||||
|
route_from: "A",
|
||||||
|
route_to: "B",
|
||||||
|
};
|
||||||
|
// Plain date-only (from the date picker).
|
||||||
|
const a = CreateTripSchema.safeParse({ ...base, trip_date: "2026-06-09" });
|
||||||
|
expect(a.success).toBe(true);
|
||||||
|
// The edit form re-submits what the API serialized for a @db.Date column
|
||||||
|
// via the Date.toJSON override ("YYYY-MM-DDT00:00:00") — must still pass and
|
||||||
|
// normalize to the date-only value.
|
||||||
|
const b = UpdateTripSchema.safeParse({ trip_date: "2026-06-09T00:00:00" });
|
||||||
|
expect(b.success).toBe(true);
|
||||||
|
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||||
|
// Genuinely malformed dates are still rejected.
|
||||||
|
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,44 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
dotenv.config({ path: ".env.test" });
|
|
||||||
|
const ENV_TEST_PATH = ".env.test";
|
||||||
|
|
||||||
|
// vitest.config.ts already loads .env.test with override:true; we reload here so
|
||||||
|
// the setup file is self-contained when run directly.
|
||||||
|
dotenv.config({ path: ENV_TEST_PATH });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and
|
||||||
|
* MUTATES it, so it must never run against a dev/production DB. We require the
|
||||||
|
* configured database name to contain "test" (the committed `.env.test` points
|
||||||
|
* at the throwaway `app_test`); anything else is a HARD FAILURE before any test
|
||||||
|
* runs, so a stray DATABASE_URL can't silently corrupt dev/prod data.
|
||||||
|
*/
|
||||||
|
function extractDbName(url: string | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
// mysql://user:pass@host:3306/dbname?params → "dbname"
|
||||||
|
const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, "");
|
||||||
|
const dbName = afterAuthority.split("?")[0].split("/")[0];
|
||||||
|
return dbName || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
const dbName = extractDbName(dbUrl);
|
||||||
|
|
||||||
|
if (!dbUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"[test-setup] DATABASE_URL is not set. The suite mutates a real database — " +
|
||||||
|
`point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbName == null || !/test/i.test(dbName)) {
|
||||||
|
throw new Error(
|
||||||
|
`[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` +
|
||||||
|
`whose name does not contain "test". The suite mutates the database — point ` +
|
||||||
|
`${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -627,19 +627,22 @@ describe("Item CRUD", () => {
|
|||||||
expect(body.data.name).toBe(`${N}item1_updated`);
|
expect(body.data.name).toBe(`${N}item1_updated`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deactivates an item (soft delete)", async () => {
|
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
|
||||||
|
// The current API HARD-deletes the item — the route does
|
||||||
|
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
|
||||||
|
// test name reflects that real behavior so a future intended switch to
|
||||||
|
// soft-delete would (correctly) make this test fail and prompt a review,
|
||||||
|
// rather than the old "soft delete" name masking the mismatch.
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||||
headers: authHeader(adminToken),
|
headers: authHeader(adminToken),
|
||||||
});
|
});
|
||||||
// The current API hard-deletes the item (the route does
|
|
||||||
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
|
|
||||||
// Verify the row is gone rather than checking is_active.
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = res.json();
|
const body = res.json();
|
||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
|
|
||||||
|
// The row is actually gone — a subsequent GET 404s.
|
||||||
const checkRes = await app.inject({
|
const checkRes = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/admin/warehouse/items/${createdItemId}`,
|
url: `/api/admin/warehouse/items/${createdItemId}`,
|
||||||
@@ -1030,6 +1033,124 @@ describe("Issue flow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6b. FIFO ordering — the OLDEST batch is consumed first
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Issue FIFO ordering (oldest batch first)", () => {
|
||||||
|
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
|
||||||
|
// Build two batches for one item via two confirmed receipts.
|
||||||
|
const itemRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/warehouse/items",
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
payload: { name: `${N}item_fifo`, unit: "ks" },
|
||||||
|
});
|
||||||
|
const fifoItemId = itemRes.json().data.id as number;
|
||||||
|
|
||||||
|
const locA = (
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/warehouse/locations",
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
payload: { code: uniqueCode(), name: "fifo loc A" },
|
||||||
|
})
|
||||||
|
).json().data.id as number;
|
||||||
|
const locB = (
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/warehouse/locations",
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
payload: { code: uniqueCode(), name: "fifo loc B" },
|
||||||
|
})
|
||||||
|
).json().data.id as number;
|
||||||
|
|
||||||
|
// Helper: create + confirm a receipt of `qty` at `locId`.
|
||||||
|
const confirmReceipt = async (qty: number, locId: number) => {
|
||||||
|
const r = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/warehouse/receipts",
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
payload: {
|
||||||
|
notes: `${N}fifo_receipt`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item_id: fifoItemId,
|
||||||
|
quantity: qty,
|
||||||
|
unit_price: 10,
|
||||||
|
location_id: locId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = r.json().data.id as number;
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/warehouse/receipts/${id}/confirm`,
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// batchEarly is created FIRST (lower id); batchLate is created SECOND
|
||||||
|
// (higher id). We then flip their received_at so the HIGHER-id batch is the
|
||||||
|
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
|
||||||
|
// by insertion id.
|
||||||
|
await confirmReceipt(10, locA); // → batch with the lower id
|
||||||
|
await confirmReceipt(10, locB); // → batch with the higher id
|
||||||
|
|
||||||
|
const batchesRaw = await prisma.sklad_batches.findMany({
|
||||||
|
where: { item_id: fifoItemId },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
});
|
||||||
|
expect(batchesRaw.length).toBe(2);
|
||||||
|
const lowerId = batchesRaw[0]; // created first
|
||||||
|
const higherId = batchesRaw[1]; // created second
|
||||||
|
|
||||||
|
// Make the HIGHER-id batch the OLDEST by received_at.
|
||||||
|
await prisma.sklad_batches.update({
|
||||||
|
where: { id: lowerId.id },
|
||||||
|
data: { received_at: new Date("2021-01-01T00:00:00Z") },
|
||||||
|
});
|
||||||
|
await prisma.sklad_batches.update({
|
||||||
|
where: { id: higherId.id },
|
||||||
|
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
|
||||||
|
// (the higher-id one we back-dated), partially depleting it.
|
||||||
|
const issueRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/warehouse/issues",
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
payload: {
|
||||||
|
project_id: projectId,
|
||||||
|
notes: `${N}fifo_issue`,
|
||||||
|
items: [{ item_id: fifoItemId, quantity: 5 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(issueRes.statusCode).toBe(201);
|
||||||
|
const issueId = issueRes.json().data.id as number;
|
||||||
|
const confirmRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||||
|
headers: authHeader(adminToken),
|
||||||
|
});
|
||||||
|
expect(confirmRes.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
|
||||||
|
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
|
||||||
|
// wrongly sorted by id it would have consumed the lower-id batch instead.
|
||||||
|
const oldest = await prisma.sklad_batches.findUnique({
|
||||||
|
where: { id: higherId.id },
|
||||||
|
});
|
||||||
|
const newest = await prisma.sklad_batches.findUnique({
|
||||||
|
where: { id: lowerId.id },
|
||||||
|
});
|
||||||
|
expect(Number(oldest!.quantity)).toBe(5);
|
||||||
|
expect(Number(newest!.quantity)).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 7. Reservation flow
|
// 7. Reservation flow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ export default function AdminApp() {
|
|||||||
{import.meta.env.DEV && UiKit && (
|
{import.meta.env.DEV && UiKit && (
|
||||||
<Route path="ui-kit" element={<UiKit />} />
|
<Route path="ui-kit" element={<UiKit />} />
|
||||||
)}
|
)}
|
||||||
|
{/*
|
||||||
|
Auth gating is delegated to <AppShell>: every authenticated
|
||||||
|
page is nested under this layout route, and AppShell
|
||||||
|
redirects to /login when there is no user (per-page
|
||||||
|
permission checks live in the pages themselves). NOTE: any
|
||||||
|
NEW top-level route added OUTSIDE this <AppShell> wrapper
|
||||||
|
(like `login`/`ui-kit` above) is PUBLIC by default — wrap it
|
||||||
|
in AppShell or add its own guard if it needs auth.
|
||||||
|
*/}
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="odin" element={<Odin />} />
|
<Route path="odin" element={<Odin />} />
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default function AlertContainer() {
|
|||||||
key={alert.id}
|
key={alert.id}
|
||||||
open
|
open
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
// Offset each stacked toast by its position in the list. Applied at
|
||||||
|
// every breakpoint (not just xs) so larger screens don't fall back to
|
||||||
|
// MUI's default bottom position and overlap with 3+ alerts.
|
||||||
|
sx={{ bottom: 16 + index * 72 }}
|
||||||
>
|
>
|
||||||
<MuiAlert
|
<MuiAlert
|
||||||
severity={alert.type}
|
severity={alert.type}
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<StatusChip
|
<StatusChip
|
||||||
key={log.id ?? i}
|
// Prefer the DB id; fall back to a project_id+index composite so
|
||||||
|
// logs without an id (e.g. active/unsaved rows) still get a key
|
||||||
|
// that's more stable than a bare index.
|
||||||
|
key={log.id ?? `${log.project_id}-${i}`}
|
||||||
color={isActive ? "info" : "default"}
|
color={isActive ? "info" : "default"}
|
||||||
label={`${log.project_name || `#${log.project_id}`} ${
|
label={`${log.project_name || `#${log.project_id}`} ${
|
||||||
durationValid
|
durationValid
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function BulkAttendanceModal({
|
|||||||
color: "primary.main",
|
color: "primary.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{form.user_ids.length === users.length
|
{users.length > 0 && form.user_ids.length === users.length
|
||||||
? "Odznačit vše"
|
? "Odznačit vše"
|
||||||
: "Vybrat vše"}
|
: "Vybrat vše"}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
// Logged to the console only — the app has no client telemetry/error
|
||||||
|
// reporting service today. This is the hook to forward to one (Sentry,
|
||||||
|
// etc.) if/when it's added.
|
||||||
console.error("ErrorBoundary caught:", error, info);
|
console.error("ErrorBoundary caught:", error, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
@@ -48,17 +48,33 @@ export default function OrderConfirmationModal({
|
|||||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// The modal is permanently mounted and merely toggled via `isOpen`, so its
|
||||||
|
// local state survives between opens and the `useState(initialItems)` snapshot
|
||||||
|
// goes stale. Re-seed everything from the current props each time the modal
|
||||||
|
// (re)opens so a fresh open always reflects the latest order data and starts
|
||||||
|
// on the "choose" step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setStep("choose");
|
||||||
|
setLang("cs");
|
||||||
|
setApplyVatState(applyVat);
|
||||||
|
setItems(initialItems);
|
||||||
|
// initialItems/applyVat are captured at open time; intentionally not in the
|
||||||
|
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleUseExisting = async () => {
|
const handleUseExisting = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onGenerate(lang, applyVatState, undefined);
|
await onGenerate(lang, applyVatState, undefined);
|
||||||
|
// Only close on success — a generation error must keep the modal open.
|
||||||
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba při generování potvrzení:", err);
|
console.error("Chyba při generování potvrzení:", err);
|
||||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setStep("choose");
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,13 +82,13 @@ export default function OrderConfirmationModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onGenerate(lang, applyVatState, items);
|
await onGenerate(lang, applyVatState, items);
|
||||||
|
// Only close on success — on error keep the user's edited items intact.
|
||||||
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba při generování potvrzení:", err);
|
console.error("Chyba při generování potvrzení:", err);
|
||||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setStep("choose");
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ export default function PlanCategoriesModal({
|
|||||||
aria-label={`Barva – ${c.label}`}
|
aria-label={`Barva – ${c.label}`}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
// Uncontrolled defaultValue won't update if the label changes
|
||||||
|
// externally (e.g. another tab renames it). Keying on c.label
|
||||||
|
// remounts the input so it re-seeds, mirroring the color input.
|
||||||
|
key={c.label}
|
||||||
defaultValue={c.label}
|
defaultValue={c.label}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
|
|||||||
@@ -28,6 +28,35 @@ interface Project {
|
|||||||
project_number?: string;
|
project_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fields shared by every plan-record save payload the modal emits. */
|
||||||
|
interface PlanRecordFields {
|
||||||
|
project_id: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for creating a plan entry (a single day or a multi-day range). */
|
||||||
|
export interface PlanEntryCreateBody extends PlanRecordFields {
|
||||||
|
user_id: number;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for updating an existing plan entry (the entry id is passed separately). */
|
||||||
|
export interface PlanEntryUpdateBody extends PlanRecordFields {
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for creating a single-day override. */
|
||||||
|
export interface PlanOverrideCreateBody extends PlanRecordFields {
|
||||||
|
user_id: number;
|
||||||
|
shift_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for updating an existing override (the override id is passed separately). */
|
||||||
|
export type PlanOverrideUpdateBody = PlanRecordFields;
|
||||||
|
|
||||||
export type PlanCellModalMode =
|
export type PlanCellModalMode =
|
||||||
| { kind: "closed" }
|
| { kind: "closed" }
|
||||||
| { kind: "create"; userId: number; date: string }
|
| { kind: "create"; userId: number; date: string }
|
||||||
@@ -74,11 +103,11 @@ interface Props {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
categories: PlanCategory[];
|
categories: PlanCategory[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaveEntry: (body: any) => Promise<void>;
|
onSaveEntry: (body: PlanEntryCreateBody) => Promise<void>;
|
||||||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
onUpdateEntry: (id: number, body: PlanEntryUpdateBody) => Promise<void>;
|
||||||
onDeleteEntry: (id: number) => Promise<void>;
|
onDeleteEntry: (id: number) => Promise<void>;
|
||||||
onSaveOverride: (body: any) => Promise<void>;
|
onSaveOverride: (body: PlanOverrideCreateBody) => Promise<void>;
|
||||||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
onUpdateOverride: (id: number, body: PlanOverrideUpdateBody) => Promise<void>;
|
||||||
onDeleteOverride: (id: number) => Promise<void>;
|
onDeleteOverride: (id: number) => Promise<void>;
|
||||||
onCreateOverrideFromRange: (
|
onCreateOverrideFromRange: (
|
||||||
entryId: number,
|
entryId: number,
|
||||||
@@ -128,47 +157,32 @@ export default function PlanCellModal(props: Props) {
|
|||||||
return <EditForm {...props} />;
|
return <EditForm {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditForm(props: Props) {
|
interface EditFormInitial {
|
||||||
const {
|
date_from: string;
|
||||||
mode,
|
date_to: string;
|
||||||
onClose,
|
is_range: boolean;
|
||||||
onSaveEntry,
|
project_id: number | null;
|
||||||
onUpdateEntry,
|
category: string;
|
||||||
onDeleteEntry,
|
note: string;
|
||||||
onSaveOverride,
|
|
||||||
onUpdateOverride,
|
|
||||||
onDeleteOverride,
|
|
||||||
projects,
|
|
||||||
} = props;
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
|
|
||||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
|
||||||
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
|
|
||||||
// "edit-override" but the parameter type is the full union, so we narrow
|
|
||||||
// explicitly here.
|
|
||||||
if (
|
|
||||||
mode.kind !== "create" &&
|
|
||||||
mode.kind !== "create-override" &&
|
|
||||||
mode.kind !== "edit-range" &&
|
|
||||||
mode.kind !== "edit-override"
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// An override is a single-day record — both editing one ("edit-override")
|
/**
|
||||||
// and creating one ("create-override") lock the date and hide the range UI.
|
* Initial form values for every mode kind. Unhandled kinds get a harmless
|
||||||
const isOverride =
|
* default (EditForm renders null for them — but only AFTER its hooks have run).
|
||||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
* Keeping this a plain function (not an inline IIFE with early returns) lets
|
||||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
* EditForm call its useState hooks unconditionally, satisfying the Rules of
|
||||||
|
* Hooks.
|
||||||
const initial = (() => {
|
*/
|
||||||
|
function computeEditFormInitial(
|
||||||
|
mode: PlanCellModalMode,
|
||||||
|
activeCategories: { key: string }[],
|
||||||
|
): EditFormInitial {
|
||||||
if (mode.kind === "create" || mode.kind === "create-override") {
|
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||||
return {
|
return {
|
||||||
date_from: mode.date,
|
date_from: mode.date,
|
||||||
date_to: mode.date,
|
date_to: mode.date,
|
||||||
is_range: false,
|
is_range: false,
|
||||||
project_id: null as number | null,
|
project_id: null,
|
||||||
// Default to "work" when it's active, otherwise the first active
|
// Default to "work" when it's active, otherwise the first active
|
||||||
// category, so a new entry never starts on a retired key.
|
// category, so a new entry never starts on a retired key.
|
||||||
category: activeCategories.some((c) => c.key === "work")
|
category: activeCategories.some((c) => c.key === "work")
|
||||||
@@ -187,7 +201,7 @@ function EditForm(props: Props) {
|
|||||||
note: mode.range.note,
|
note: mode.range.note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// mode.kind === "edit-override" (narrowed above)
|
if (mode.kind === "edit-override") {
|
||||||
return {
|
return {
|
||||||
date_from: mode.date,
|
date_from: mode.date,
|
||||||
date_to: mode.date,
|
date_to: mode.date,
|
||||||
@@ -196,8 +210,41 @@ function EditForm(props: Props) {
|
|||||||
category: mode.cell.category,
|
category: mode.cell.category,
|
||||||
note: mode.cell.note,
|
note: mode.cell.note,
|
||||||
};
|
};
|
||||||
})();
|
}
|
||||||
|
// Unhandled kinds: harmless defaults (EditForm returns null after its hooks).
|
||||||
|
return {
|
||||||
|
date_from: "",
|
||||||
|
date_to: "",
|
||||||
|
is_range: false,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditForm(props: Props) {
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onSaveEntry,
|
||||||
|
onUpdateEntry,
|
||||||
|
onDeleteEntry,
|
||||||
|
onSaveOverride,
|
||||||
|
onUpdateOverride,
|
||||||
|
onDeleteOverride,
|
||||||
|
projects,
|
||||||
|
} = props;
|
||||||
|
// An override is a single-day record — both editing one ("edit-override")
|
||||||
|
// and creating one ("create-override") lock the date and hide the range UI.
|
||||||
|
const isOverride =
|
||||||
|
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||||
|
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||||
|
// Computed for ALL mode kinds (the helper has a fallback) so the hooks below
|
||||||
|
// are unconditional.
|
||||||
|
const initial = computeEditFormInitial(mode, activeCategories);
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||||
const [isRange, setIsRange] = useState(initial.is_range);
|
const [isRange, setIsRange] = useState(initial.is_range);
|
||||||
@@ -205,6 +252,18 @@ function EditForm(props: Props) {
|
|||||||
const [category, setCategory] = useState(initial.category);
|
const [category, setCategory] = useState(initial.category);
|
||||||
const [note, setNote] = useState(initial.note);
|
const [note, setNote] = useState(initial.note);
|
||||||
|
|
||||||
|
// Narrow mode to the kinds EditForm actually handles. The call site only ever
|
||||||
|
// passes the four editable kinds; we guard AFTER all hooks so the hook order
|
||||||
|
// is identical on every render (Rules of Hooks).
|
||||||
|
if (
|
||||||
|
mode.kind !== "create" &&
|
||||||
|
mode.kind !== "create-override" &&
|
||||||
|
mode.kind !== "edit-range" &&
|
||||||
|
mode.kind !== "edit-override"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
mode.kind === "create"
|
mode.kind === "create"
|
||||||
? "Nový záznam plánu"
|
? "Nový záznam plánu"
|
||||||
|
|||||||
@@ -466,9 +466,23 @@ export default function PlanGrid({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const today = useMemo(() => todayIso(), []);
|
const today = useMemo(() => todayIso(), []);
|
||||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||||
|
// Index projects by id once per projects change instead of a linear
|
||||||
|
// projects.find() inside the per-cell render loop (which was
|
||||||
|
// O(days * users * cells * projects)). Hooks must run before the early
|
||||||
|
// return below, so this is computed unconditionally.
|
||||||
|
const projectMap = useMemo(() => {
|
||||||
|
const m = new Map<number, Project>();
|
||||||
|
for (const p of projects) m.set(p.id, p);
|
||||||
|
return m;
|
||||||
|
}, [projects]);
|
||||||
|
// Memoize the day list so it isn't rebuilt on every render (only when the
|
||||||
|
// visible range changes). Stable identity also keeps the row map cheap.
|
||||||
|
const days = useMemo(
|
||||||
|
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
||||||
|
[data?.date_from, data?.date_to],
|
||||||
|
);
|
||||||
|
|
||||||
if (!data) return <LoadingState />;
|
if (!data) return <LoadingState />;
|
||||||
const days = eachDay(data.date_from, data.date_to);
|
|
||||||
const users = data.users;
|
const users = data.users;
|
||||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||||
// mutation on the same cell re-triggers the animation (CSS animations
|
// mutation on the same cell re-triggers the animation (CSS animations
|
||||||
@@ -610,12 +624,9 @@ export default function PlanGrid({
|
|||||||
cell={c}
|
cell={c}
|
||||||
project={
|
project={
|
||||||
c.project_id
|
c.project_id
|
||||||
? (projects.find(
|
? (projectMap.get(c.project_id) ?? null)
|
||||||
(p) => p.id === c.project_id,
|
|
||||||
) ?? null)
|
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
readonly={!canEdit}
|
|
||||||
categoryLabel={planCategoryLabel(
|
categoryLabel={planCategoryLabel(
|
||||||
c.category,
|
c.category,
|
||||||
catMap,
|
catMap,
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import type { Project } from "../lib/queries/projects";
|
|||||||
interface Props {
|
interface Props {
|
||||||
cell: ResolvedCell | null;
|
cell: ResolvedCell | null;
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
readonly?: boolean;
|
|
||||||
categoryLabel: string;
|
categoryLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purely presentational — renders the category/project chips and note for a
|
||||||
|
// single resolved plan cell. It has no interactive handlers of its own (the
|
||||||
|
// click target is the cell <button> in PlanGrid, which already applies the
|
||||||
|
// read-only styling/behaviour), so there is no read-only state to gate here.
|
||||||
export default function PlanRangeChips({
|
export default function PlanRangeChips({
|
||||||
cell,
|
cell,
|
||||||
project,
|
project,
|
||||||
readonly,
|
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void readonly;
|
|
||||||
if (!cell) return null;
|
if (!cell) return null;
|
||||||
// Prefer the server-embedded project label (always present). Fall back to
|
// Prefer the server-embedded project label (always present). Fall back to
|
||||||
// the looked-up `project` prop only if the cell predates the embed (stale
|
// the looked-up `project` prop only if the cell predates the embed (stale
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ export default function ProjectFileManager({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isCancelling = useRef(false);
|
const isCancelling = useRef(false);
|
||||||
|
// dragenter/dragleave fire on every child boundary crossed during a drag.
|
||||||
|
// Counting enter/leave events keeps the overlay steady instead of flickering
|
||||||
|
// as the cursor moves over nested elements; the overlay only hides when the
|
||||||
|
// depth returns to 0 (the cursor truly left the drop zone).
|
||||||
|
const dragDepth = useRef(0);
|
||||||
|
|
||||||
const [currentPath, setCurrentPath] = useState("");
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
|
|
||||||
@@ -333,7 +338,8 @@ export default function ProjectFileManager({
|
|||||||
} else {
|
} else {
|
||||||
errorMsg = data.error || "Chyba při nahrávání";
|
errorMsg = data.error || "Chyba při nahrávání";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("ProjectFileManager: upload failed", e);
|
||||||
errorMsg = "Chyba připojení";
|
errorMsg = "Chyba připojení";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,21 +368,32 @@ export default function ProjectFileManager({
|
|||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
dragDepth.current = 0;
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
if (!canManage) return;
|
if (!canManage) return;
|
||||||
handleUpload(e.dataTransfer.files);
|
handleUpload(e.dataTransfer.files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canManage) {
|
if (!canManage) return;
|
||||||
|
dragDepth.current += 1;
|
||||||
setDragOver(true);
|
setDragOver(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
// Must preventDefault on dragover too, otherwise the browser rejects the
|
||||||
|
// drop. The overlay state is driven by enter/leave (see dragDepth).
|
||||||
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!canManage) return;
|
||||||
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
|
if (dragDepth.current === 0) {
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
@@ -405,7 +422,8 @@ export default function ProjectFileManager({
|
|||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("ProjectFileManager: create folder failed", e);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingFolder(false);
|
setCreatingFolder(false);
|
||||||
@@ -435,7 +453,8 @@ export default function ProjectFileManager({
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("ProjectFileManager: download failed", e);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -468,7 +487,8 @@ export default function ProjectFileManager({
|
|||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se smazat");
|
alert.error(data.error || "Nepodařilo se smazat");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("ProjectFileManager: delete failed", e);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
@@ -505,7 +525,8 @@ export default function ProjectFileManager({
|
|||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("ProjectFileManager: rename failed", e);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
setRenamingItem(null);
|
setRenamingItem(null);
|
||||||
@@ -887,6 +908,7 @@ export default function ProjectFileManager({
|
|||||||
{/* Drop zone + table */}
|
{/* Drop zone + table */}
|
||||||
<Box
|
<Box
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export default function RichEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(content: string, _delta: any, source: string) => {
|
// `_delta` is react-quill-new's loosely-typed Delta object; we never read
|
||||||
|
// it, so `unknown` is enough and avoids an `any` escape hatch.
|
||||||
|
(content: string, _delta: unknown, source: string) => {
|
||||||
if (source !== "user") return;
|
if (source !== "user") return;
|
||||||
if (content === lastValueRef.current) return;
|
if (content === lastValueRef.current) return;
|
||||||
lastValueRef.current = content;
|
lastValueRef.current = content;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
@@ -17,8 +18,6 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
|
||||||
let _logKeyCounter = 0;
|
|
||||||
|
|
||||||
// ---------- Shared types ----------
|
// ---------- Shared types ----------
|
||||||
|
|
||||||
export interface ShiftFormData {
|
export interface ShiftFormData {
|
||||||
@@ -226,6 +225,11 @@ export default function ShiftFormModal({
|
|||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
|
// Per-instance counter for stable React keys on newly-added project rows.
|
||||||
|
// (Was a module-level mutable `let`, which is shared across every modal
|
||||||
|
// instance and fragile under StrictMode / concurrent instances.)
|
||||||
|
const logKeyCounter = useRef(0);
|
||||||
|
|
||||||
const updateField = (field: keyof ShiftFormData, value: string | number) => {
|
const updateField = (field: keyof ShiftFormData, value: string | number) => {
|
||||||
setForm({ ...form, [field]: value });
|
setForm({ ...form, [field]: value });
|
||||||
};
|
};
|
||||||
@@ -244,7 +248,7 @@ export default function ShiftFormModal({
|
|||||||
setProjectLogs([
|
setProjectLogs([
|
||||||
...projectLogs,
|
...projectLogs,
|
||||||
{
|
{
|
||||||
_key: `log-${++_logKeyCounter}`,
|
_key: `log-${++logKeyCounter.current}`,
|
||||||
project_id: "",
|
project_id: "",
|
||||||
hours: "",
|
hours: "",
|
||||||
minutes: "",
|
minutes: "",
|
||||||
@@ -327,7 +331,9 @@ export default function ShiftFormModal({
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={form.leave_hours}
|
value={form.leave_hours}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField("leave_hours", parseFloat(e.target.value))
|
// Empty input -> parseFloat returns NaN, which serializes to an
|
||||||
|
// invalid value; fall back to 0 like the Number(...)||0 pattern.
|
||||||
|
updateField("leave_hours", Number(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
@@ -431,7 +437,10 @@ export default function ShiftFormModal({
|
|||||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||||
{projectLogs.map((log, i) => (
|
{projectLogs.map((log, i) => (
|
||||||
<ProjectLogRow
|
<ProjectLogRow
|
||||||
key={log._key || i}
|
// Stable key: client-added rows carry `_key`; server-loaded rows
|
||||||
|
// carry a DB `id`. Fall back to the index only when neither is
|
||||||
|
// present (shouldn't happen, but avoids a key collision).
|
||||||
|
key={log._key ?? (log.id != null ? `id-${log.id}` : i)}
|
||||||
log={log}
|
log={log}
|
||||||
index={i}
|
index={i}
|
||||||
projectList={projectList}
|
projectList={projectList}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Intentional no-op: this component is still mounted by AppShell.tsx as a
|
||||||
|
// placeholder for a future keyboard-shortcuts help overlay. It renders nothing
|
||||||
|
// today. Keep the harmless `null` return (and the AppShell import) until the
|
||||||
|
// overlay is actually built — do not delete the file while AppShell references it.
|
||||||
export default function ShortcutsHelp() {
|
export default function ShortcutsHelp() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
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 { StatCard, type StatCardColor } from "../../ui";
|
import { StatCard, type StatCardColor } from "../../ui";
|
||||||
@@ -121,6 +121,7 @@ const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
const kpiCards = buildKpiCards(dashData);
|
const kpiCards = buildKpiCards(dashData);
|
||||||
if (kpiCards.length === 0) {
|
if (kpiCards.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -129,8 +130,8 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component={motion.div}
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useQueryClient } 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";
|
||||||
@@ -125,6 +126,8 @@ export default function DashProfile({
|
|||||||
}: DashProfileProps) {
|
}: DashProfileProps) {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
const totpSetupRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
|
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
|
||||||
@@ -156,21 +159,30 @@ export default function DashProfile({
|
|||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const dataToSave = { ...formData };
|
|
||||||
|
|
||||||
if (dataToSave.new_password && !dataToSave.current_password) {
|
if (formData.new_password && !formData.current_password) {
|
||||||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dataToSave.current_password && !dataToSave.new_password) {
|
if (formData.current_password && !formData.new_password) {
|
||||||
alert.error("Pro změnu hesla zadejte nové heslo");
|
alert.error("Pro změnu hesla zadejte nové heslo");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip empty password fields so Zod doesn't reject ""
|
// Build the payload with the password fields optional so empty ones can be
|
||||||
if (!dataToSave.current_password)
|
// omitted (Zod rejects ""), without resorting to `as any` deletes.
|
||||||
delete (dataToSave as any).current_password;
|
const dataToSave: Partial<
|
||||||
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
|
Pick<ProfileFormData, "current_password" | "new_password">
|
||||||
|
> &
|
||||||
|
Omit<ProfileFormData, "current_password" | "new_password"> = {
|
||||||
|
username: formData.username,
|
||||||
|
email: formData.email,
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
};
|
||||||
|
if (formData.current_password)
|
||||||
|
dataToSave.current_password = formData.current_password;
|
||||||
|
if (formData.new_password) dataToSave.new_password = formData.new_password;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||||
@@ -185,13 +197,21 @@ export default function DashProfile({
|
|||||||
email: dataToSave.email,
|
email: dataToSave.email,
|
||||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||||
});
|
});
|
||||||
|
// Refresh anything keyed on the current user's data so stale views
|
||||||
|
// (dashboard widgets, user lists) pick up the edited profile.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
||||||
|
// before the success toast appears, so the toast doesn't flash over the
|
||||||
|
// still-fading dialog.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success("Profil byl upraven");
|
alert.success("Profil byl upraven");
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se uložit profil");
|
alert.error(data.error || "Nepodařilo se uložit profil");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("DashProfile: uložení profilu selhalo", err);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -216,8 +236,8 @@ export default function DashProfile({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
@@ -16,6 +17,14 @@ interface Vehicle {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard { success, data, error, message } envelope returned by the API.
|
||||||
|
interface ApiResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TripForm {
|
interface TripForm {
|
||||||
vehicle_id: string;
|
vehicle_id: string;
|
||||||
trip_date: string;
|
trip_date: string;
|
||||||
@@ -61,6 +70,8 @@ export default function DashQuickActions({
|
|||||||
}: DashQuickActionsProps) {
|
}: DashQuickActionsProps) {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
|
||||||
const [showTripModal, setShowTripModal] = useState(false);
|
const [showTripModal, setShowTripModal] = useState(false);
|
||||||
const [tripSubmitting, setTripSubmitting] = useState(false);
|
const [tripSubmitting, setTripSubmitting] = useState(false);
|
||||||
@@ -93,16 +104,17 @@ export default function DashQuickActions({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/vehicles`);
|
const response = await apiFetch(`${API_BASE}/vehicles`);
|
||||||
const result = await response.json();
|
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
|
||||||
|
await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTripVehicles(
|
setTripVehicles(
|
||||||
Array.isArray(result.data)
|
Array.isArray(result.data)
|
||||||
? result.data
|
? result.data
|
||||||
: result.data?.vehicles || [],
|
: (result.data?.vehicles ?? []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// vozidla se nenacetla
|
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,12 +125,16 @@ export default function DashQuickActions({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
||||||
const result = await response.json();
|
const result: ApiResult<{ last_km: number | string }> =
|
||||||
if (result.success) {
|
await response.json();
|
||||||
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
|
if (result.success && result.data) {
|
||||||
|
setTripForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
start_km: String(result.data!.last_km ?? ""),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// last_km se nenacetlo
|
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,7 +155,7 @@ export default function DashQuickActions({
|
|||||||
if (
|
if (
|
||||||
tripForm.start_km &&
|
tripForm.start_km &&
|
||||||
tripForm.end_km &&
|
tripForm.end_km &&
|
||||||
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
|
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
|
||||||
) {
|
) {
|
||||||
errs.end_km = "Musí být větší než počáteční";
|
errs.end_km = "Musí být větší než počáteční";
|
||||||
}
|
}
|
||||||
@@ -161,14 +177,19 @@ export default function DashQuickActions({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(tripForm),
|
body: JSON.stringify(tripForm),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result: ApiResult<unknown> = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// A new trip changes the trip list and the dashboard vehicle widgets —
|
||||||
|
// invalidate the broad domains (prefix-matching covers sub-queries).
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
setShowTripModal(false);
|
setShowTripModal(false);
|
||||||
alert.success(result.message);
|
alert.success(result.message ?? "Jízda uložena");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error ?? "Uložení jízdy selhalo");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("DashQuickActions: uložení jízdy selhalo", e);
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
setTripSubmitting(false);
|
setTripSubmitting(false);
|
||||||
@@ -176,8 +197,8 @@ export default function DashQuickActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tripDistance = (): number => {
|
const tripDistance = (): number => {
|
||||||
const s = parseInt(tripForm.start_km) || 0;
|
const s = parseInt(tripForm.start_km, 10) || 0;
|
||||||
const e = parseInt(tripForm.end_km) || 0;
|
const e = parseInt(tripForm.end_km, 10) || 0;
|
||||||
return e > s ? e - s : 0;
|
return e > s ? e - s : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,8 +315,8 @@ export default function DashQuickActions({
|
|||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
component={motion.div}
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
transition={{ duration: 0.25, delay: 0.08 }}
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
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";
|
||||||
@@ -69,6 +69,7 @@ function getDeviceIcon(iconType?: string) {
|
|||||||
export default function DashSessions() {
|
export default function DashSessions() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
|
||||||
const { data: sessions = [], isPending: sessionsLoading } =
|
const { data: sessions = [], isPending: sessionsLoading } =
|
||||||
useQuery(sessionsOptions());
|
useQuery(sessionsOptions());
|
||||||
@@ -128,8 +129,8 @@ export default function DashSessions() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
>
|
>
|
||||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
|||||||
@@ -125,7 +125,19 @@ export default function OdinSidebar({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={busy ? -1 : 0}
|
||||||
|
aria-pressed={isActive}
|
||||||
onClick={() => !busy && onSelect(conv.id)}
|
onClick={() => !busy && onSelect(conv.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Activate the row on Enter/Space like a native button. The
|
||||||
|
// nested kebab IconButton stays a real <button> and handles its
|
||||||
|
// own keys (its onClick stopPropagation prevents row selection).
|
||||||
|
if (!busy && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(conv.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -139,6 +151,7 @@ export default function OdinSidebar({
|
|||||||
bgcolor: isActive ? "action.selected" : "action.hover",
|
bgcolor: isActive ? "action.selected" : "action.hover",
|
||||||
},
|
},
|
||||||
"&:hover .odin-conv-menu": { opacity: 1 },
|
"&:hover .odin-conv-menu": { opacity: 1 },
|
||||||
|
"&:focus-visible .odin-conv-menu": { opacity: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
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 useDebounce from "../../hooks/useDebounce";
|
||||||
import {
|
import {
|
||||||
warehouseItemListOptions,
|
warehouseItemListOptions,
|
||||||
type WarehouseItem,
|
type WarehouseItem,
|
||||||
@@ -26,9 +27,11 @@ export default function ItemPicker({
|
|||||||
itemName,
|
itemName,
|
||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||||
|
// Debounce the search so the list query doesn't fire on every keystroke.
|
||||||
|
const debouncedSearch = useDebounce(inputValue, 300);
|
||||||
|
|
||||||
const { data, isFetching } = useQuery(
|
const { data, isFetching } = useQuery(
|
||||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
warehouseItemListOptions({ search: debouncedSearch, perPage: 20 }),
|
||||||
);
|
);
|
||||||
const options: ItemOption[] = data?.data ?? [];
|
const options: ItemOption[] = data?.data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { Select } from "../../ui";
|
import { Select } from "../../ui";
|
||||||
@@ -16,7 +17,7 @@ export default function ReservationPicker({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: ReservationPickerProps) {
|
}: ReservationPickerProps) {
|
||||||
const { data: result } = useQuery(
|
const { data: result, isFetching } = useQuery(
|
||||||
warehouseReservationListOptions({
|
warehouseReservationListOptions({
|
||||||
item_id: itemId ?? undefined,
|
item_id: itemId ?? undefined,
|
||||||
project_id: projectId ?? undefined,
|
project_id: projectId ?? undefined,
|
||||||
@@ -27,6 +28,22 @@ export default function ReservationPicker({
|
|||||||
|
|
||||||
const reservations = result?.data ?? [];
|
const reservations = result?.data ?? [];
|
||||||
|
|
||||||
|
// When item/project change, the loaded reservation list changes. If the
|
||||||
|
// currently-selected reservation is no longer available, clear it and notify
|
||||||
|
// the parent so it doesn't keep holding a stale reservationId. Only act once
|
||||||
|
// the query has settled (result present, not fetching) to avoid resetting
|
||||||
|
// while the new list is still loading.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
value != null &&
|
||||||
|
result !== undefined &&
|
||||||
|
!isFetching &&
|
||||||
|
!reservations.some((r) => r.id === value)
|
||||||
|
) {
|
||||||
|
onChange(null, 0);
|
||||||
|
}
|
||||||
|
}, [value, result, isFetching, reservations, onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={value == null ? "" : String(value)}
|
value={value == null ? "" : String(value)}
|
||||||
@@ -38,7 +55,7 @@ export default function ReservationPicker({
|
|||||||
const reservationId = Number(val);
|
const reservationId = Number(val);
|
||||||
const reservation = reservations.find((r) => r.id === reservationId);
|
const reservation = reservations.find((r) => r.id === reservationId);
|
||||||
if (reservation) {
|
if (reservation) {
|
||||||
onChange(reservationId, Number(reservation.remaining_qty));
|
onChange(reservationId, reservation.remaining_qty);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -46,7 +63,7 @@ export default function ReservationPicker({
|
|||||||
{reservations.map((r) => (
|
{reservations.map((r) => (
|
||||||
<MenuItem key={r.id} value={String(r.id)}>
|
<MenuItem key={r.id} value={String(r.id)}>
|
||||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
{r.remaining_qty} {r.item?.unit ?? "ks"}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
);
|
||||||
|
|
||||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||||
@@ -315,6 +315,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [getAccessTokenFn]);
|
}, [getAccessTokenFn]);
|
||||||
|
|
||||||
|
// NOTE: this is a SECOND 401-refresh-and-retry path that parallels
|
||||||
|
// `apiFetch` in src/admin/utils/api.ts (which most pages use via the query
|
||||||
|
// helpers). The two diverge: `apiRequest` ignores AbortSignals and does NOT
|
||||||
|
// set the session-expired flag, whereas `apiFetch` dedupes concurrent
|
||||||
|
// refreshes, honors abort, and surfaces the 499 session-expired sentinel.
|
||||||
|
// Consolidating onto `apiFetch` is a tracked follow-up but is deferred here
|
||||||
|
// because it touches the core auth/refresh flow (high regression risk). If
|
||||||
|
// you change refresh/retry semantics in one, mirror it in the other.
|
||||||
const apiRequest = useCallback(
|
const apiRequest = useCallback(
|
||||||
async (endpoint: string, options: RequestInit = {}) => {
|
async (endpoint: string, options: RequestInit = {}) => {
|
||||||
let token = getAccessTokenFn();
|
let token = getAccessTokenFn();
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// NOTE: this hook locks `document.body.style.overflow` only. The app layout
|
||||||
|
// scrolls on `<html>` (GlobalStyles sets `html { overflow-x: hidden }`, which
|
||||||
|
// makes `<html>` the scroll container), so a body-only lock may not fully
|
||||||
|
// prevent background scroll. The kit's Modal/ConfirmDialog use
|
||||||
|
// `useDialogScrollLock`, which correctly locks `<html>`. This hook is retained
|
||||||
|
// only for the two non-kit in-page edit modals that still call it
|
||||||
|
// (AttendanceAdmin, WarehouseItemDetail); prefer `useDialogScrollLock` for new
|
||||||
|
// dialogs. Behavior intentionally left unchanged to avoid regressing those
|
||||||
|
// pages — see REVIEW_FINDINGS (useModalLock LOW).
|
||||||
let activeLocks = 0;
|
let activeLocks = 0;
|
||||||
|
|
||||||
export default function useModalLock(isOpen: boolean): void {
|
export default function useModalLock(isOpen: boolean): void {
|
||||||
|
|||||||
@@ -19,9 +19,18 @@ interface PaginatedResult<T> {
|
|||||||
* Wrapper around useQuery for paginated list endpoints.
|
* Wrapper around useQuery for paginated list endpoints.
|
||||||
* Accepts the return value of queryOptions() from lib/queries/*
|
* Accepts the return value of queryOptions() from lib/queries/*
|
||||||
* and extracts items + pagination from the response.
|
* and extracts items + pagination from the response.
|
||||||
|
*
|
||||||
|
* `options` is intentionally `any`: the query helpers pass a `queryOptions(...)`
|
||||||
|
* object whose `queryKey` is a concrete mutable tuple, and TanStack's `enabled`
|
||||||
|
* predicate is contravariant in the key type — pinning the key generic here
|
||||||
|
* (even via an inferred `TKey`) fails to unify for several callers. The data
|
||||||
|
* shape we rely on (`PaginatedResult<T>`) is enforced by the cast below, so the
|
||||||
|
* loss of input typing on `options` is contained to this thin wrapper.
|
||||||
*/
|
*/
|
||||||
|
export function usePaginatedQuery<T>(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function usePaginatedQuery<T>(options: any) {
|
options: any,
|
||||||
|
) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
...options,
|
...options,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -59,6 +59,49 @@ async function apiCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request-body shapes for the plan mutations. The server validates the full
|
||||||
|
// schema; these capture the fields the optimistic patches read.
|
||||||
|
export interface PlanEntryBody {
|
||||||
|
user_id: number;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
project_id?: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanEntryPatchBody {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
project_id?: number | null;
|
||||||
|
category?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanOverrideBody {
|
||||||
|
user_id: number;
|
||||||
|
shift_date: string;
|
||||||
|
project_id?: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanOverridePatchBody {
|
||||||
|
project_id?: number | null;
|
||||||
|
category?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkCreateBody {
|
||||||
|
user_ids: number[];
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsePlanWorkArgs {
|
export interface UsePlanWorkArgs {
|
||||||
initialDate?: Date;
|
initialDate?: Date;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -88,10 +131,24 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
|
|
||||||
const gridQuery = useQuery({
|
const gridQuery = useQuery({
|
||||||
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
||||||
queryFn: () =>
|
queryFn: async (): Promise<GridData> => {
|
||||||
apiFetch(
|
const res = await apiFetch(
|
||||||
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
||||||
).then((r) => r.json().then((b: any) => b.data as GridData)),
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = "Chyba serveru";
|
||||||
|
try {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (errBody && typeof errBody.error === "string")
|
||||||
|
message = errBody.error;
|
||||||
|
} catch {
|
||||||
|
// body wasn't JSON; use default
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { data: GridData };
|
||||||
|
return body.data;
|
||||||
|
},
|
||||||
// keepPreviousData holds the old range's data visible while the new
|
// keepPreviousData holds the old range's data visible while the new
|
||||||
// range's fetch is in flight. Without this, `gridQuery.data` is
|
// range's fetch is in flight. Without this, `gridQuery.data` is
|
||||||
// immediately undefined on key change and the grid flashes empty.
|
// immediately undefined on key change and the grid flashes empty.
|
||||||
@@ -232,20 +289,54 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Mutations ---
|
// --- Mutations ---
|
||||||
|
//
|
||||||
|
// Optimistic-update pattern (idiomatic TanStack Query):
|
||||||
|
// onMutate → apply the optimistic grid patch and RETURN the rollback
|
||||||
|
// snapshot as this invocation's context (concurrency-safe:
|
||||||
|
// each in-flight mutation owns its own context object, so two
|
||||||
|
// concurrent mutations can't clobber each other's snapshot —
|
||||||
|
// the previous `(mutation as any)._rolled = …` stash could).
|
||||||
|
// onError → restore the cells captured in onMutate's context.
|
||||||
|
// onSettled → invalidate the domain so the server (source of truth)
|
||||||
|
// reconciles the patch on both success AND error.
|
||||||
|
// The visible UX is unchanged: the new/updated cell still appears
|
||||||
|
// immediately and the broad invalidate still refetches the grid.
|
||||||
|
|
||||||
const createEntry = useMutation({
|
type CellRollback = {
|
||||||
mutationFn: (body: any) =>
|
key: readonly unknown[];
|
||||||
|
userId: number;
|
||||||
|
rolled: Record<string, ResolvedCell[]> | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// Restore the cells a patch overwrote, from an onMutate-returned context.
|
||||||
|
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||||
|
function rollbackCells(ctx: CellRollback | undefined) {
|
||||||
|
if (!ctx || !ctx.rolled) return;
|
||||||
|
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
||||||
|
if (!prev) return;
|
||||||
|
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||||
|
const userNext = { ...userPrev };
|
||||||
|
for (const [date, cells] of Object.entries(ctx.rolled)) {
|
||||||
|
userNext[date] = cells;
|
||||||
|
}
|
||||||
|
qc.setQueryData(ctx.key, {
|
||||||
|
...prev,
|
||||||
|
cells: { ...prev.cells, [ctx.userId]: userNext },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEntry = useMutation<unknown, Error, PlanEntryBody, CellRollback>({
|
||||||
|
mutationFn: (body: PlanEntryBody) =>
|
||||||
apiCall("/api/admin/plan/entries", {
|
apiCall("/api/admin/plan/entries", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
onSuccess: (data: any, body: any) => {
|
onMutate: (body): CellRollback => {
|
||||||
// Patch the visible grid with the new entry. We use the response
|
// Patch the visible grid with the new entry. We don't have the
|
||||||
// id (server-assigned) for the new entry's entryId; the rest of
|
// server-assigned id yet (entryId stays null) — the refetch fills it
|
||||||
// the ResolvedCell mirrors the request body.
|
// within ~200ms; PlanGrid only uses entryId for the edit flow.
|
||||||
const days = eachDay(body.date_from, body.date_to);
|
const days = eachDay(body.date_from, body.date_to);
|
||||||
const id = data && typeof data.id === "number" ? data.id : null;
|
|
||||||
const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [
|
const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [
|
||||||
makeEntryCell({
|
makeEntryCell({
|
||||||
userId: body.user_id,
|
userId: body.user_id,
|
||||||
@@ -255,40 +346,35 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
note: body.note,
|
note: body.note,
|
||||||
rangeFrom: body.date_from,
|
rangeFrom: body.date_from,
|
||||||
rangeTo: body.date_to,
|
rangeTo: body.date_to,
|
||||||
entryId: id,
|
entryId: null,
|
||||||
}),
|
}),
|
||||||
...prev,
|
...prev,
|
||||||
]);
|
]);
|
||||||
// Stash the rollback on the mutation object so PlanWork can call
|
return { key: currentGridKey, userId: body.user_id, rolled };
|
||||||
// it from onError.
|
|
||||||
(createEntry as any)._rolled = rolled;
|
|
||||||
invalidate();
|
|
||||||
},
|
},
|
||||||
|
onError: (_err, _body, ctx) => rollbackCells(ctx),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateEntry = useMutation({
|
const updateEntry = useMutation<
|
||||||
mutationFn: ({
|
unknown,
|
||||||
id,
|
Error,
|
||||||
body,
|
{ id: number; body: PlanEntryPatchBody; force?: boolean },
|
||||||
force,
|
CellRollback
|
||||||
}: {
|
>({
|
||||||
id: number;
|
mutationFn: ({ id, body, force }) =>
|
||||||
body: any;
|
|
||||||
force?: boolean;
|
|
||||||
}) =>
|
|
||||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
onSuccess: (_data, vars) => {
|
onMutate: ({ id, body }): CellRollback => {
|
||||||
// We don't know the new full range without the response, but we
|
// We don't know the new full range without the response, but we
|
||||||
// do have the body's date_from/date_to. If those are present,
|
// do have the body's date_from/date_to. If those are present,
|
||||||
// patch the new range. Old cells outside the new range are NOT
|
// patch the new range. Old cells outside the new range are NOT
|
||||||
// cleared here — they'll either still be valid (date_from/to
|
// cleared here — they'll either still be valid (date_from/to
|
||||||
// were partial updates) or the refetch will fix them.
|
// were partial updates) or the refetch will fix them.
|
||||||
const { id, body } = vars;
|
if (!body.date_from || !body.date_to) return null;
|
||||||
if (body.date_from && body.date_to) {
|
|
||||||
const days = eachDay(body.date_from, body.date_to);
|
const days = eachDay(body.date_from, body.date_to);
|
||||||
// Find the user that owns this entry in the current grid by
|
// Find the user that owns this entry in the current grid by
|
||||||
// looking for any cell with entryId === id (we already know
|
// looking for any cell with entryId === id (we already know
|
||||||
@@ -306,12 +392,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
if (ownerUserId !== null) break;
|
if (ownerUserId !== null) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ownerUserId !== null) {
|
if (ownerUserId === null) return null;
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => {
|
||||||
currentGridKey,
|
|
||||||
ownerUserId,
|
|
||||||
days,
|
|
||||||
(prev) => {
|
|
||||||
const existing = prev.find((c) => c.entryId === id) ?? null;
|
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||||
const updated = makeEntryCell({
|
const updated = makeEntryCell({
|
||||||
userId: ownerUserId!,
|
userId: ownerUserId!,
|
||||||
@@ -322,68 +404,76 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
: body.project_id,
|
: body.project_id,
|
||||||
category: body.category ?? existing?.category ?? "work",
|
category: body.category ?? existing?.category ?? "work",
|
||||||
note: body.note ?? existing?.note ?? "",
|
note: body.note ?? existing?.note ?? "",
|
||||||
rangeFrom: body.date_from,
|
rangeFrom: body.date_from!,
|
||||||
rangeTo: body.date_to,
|
rangeTo: body.date_to!,
|
||||||
entryId: id,
|
entryId: id,
|
||||||
});
|
});
|
||||||
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||||
|
});
|
||||||
|
return { key: currentGridKey, userId: ownerUserId, rolled };
|
||||||
},
|
},
|
||||||
);
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||||
(updateEntry as any)._rolled = rolled;
|
onSettled: () => invalidate(),
|
||||||
}
|
|
||||||
}
|
|
||||||
invalidate();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteEntry = useMutation({
|
const deleteEntry = useMutation<
|
||||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ id: number; force?: boolean },
|
||||||
|
CellRollback
|
||||||
|
>({
|
||||||
|
mutationFn: ({ id, force }) =>
|
||||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
onSuccess: (_data, vars) => {
|
onMutate: (vars): CellRollback => {
|
||||||
// For delete we need to know the entry's user_id and full range.
|
// For delete we need to know the entry's user_id and full range.
|
||||||
// Look it up from the current grid: find the user that has a cell
|
// Look it up from the current grid: find the user that has a cell
|
||||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
let range: { from: string; to: string } | null = null;
|
let entryRange: { from: string; to: string } | null = null;
|
||||||
for (const cells of Object.values(byDate)) {
|
for (const cells of Object.values(byDate)) {
|
||||||
const hit = cells.find(
|
const hit = cells.find(
|
||||||
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||||
);
|
);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
entryRange = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (range) {
|
if (entryRange) {
|
||||||
const days = eachDay(range.from, range.to);
|
const days = eachDay(entryRange.from, entryRange.to);
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(
|
||||||
currentGridKey,
|
currentGridKey,
|
||||||
Number(uidStr),
|
Number(uidStr),
|
||||||
days,
|
days,
|
||||||
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||||
);
|
);
|
||||||
(deleteEntry as any)._rolled = rolled;
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
invalidate();
|
|
||||||
},
|
},
|
||||||
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOverride = useMutation({
|
const createOverride = useMutation<
|
||||||
mutationFn: (body: any) =>
|
unknown,
|
||||||
|
Error,
|
||||||
|
PlanOverrideBody,
|
||||||
|
CellRollback
|
||||||
|
>({
|
||||||
|
mutationFn: (body: PlanOverrideBody) =>
|
||||||
apiCall("/api/admin/plan/overrides", {
|
apiCall("/api/admin/plan/overrides", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
onSuccess: (data: any, vars: any) => {
|
onMutate: (vars): CellRollback => {
|
||||||
const id = data && typeof data.id === "number" ? data.id : null;
|
// overrideId stays null optimistically; the refetch fills the real id.
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(
|
||||||
currentGridKey,
|
currentGridKey,
|
||||||
vars.user_id,
|
vars.user_id,
|
||||||
@@ -395,36 +485,34 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
projectId: vars.project_id ?? null,
|
projectId: vars.project_id ?? null,
|
||||||
category: vars.category,
|
category: vars.category,
|
||||||
note: vars.note,
|
note: vars.note,
|
||||||
overrideId: id,
|
overrideId: null,
|
||||||
}),
|
}),
|
||||||
...prev,
|
...prev,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
(createOverride as any)._rolled = rolled;
|
return { key: currentGridKey, userId: vars.user_id, rolled };
|
||||||
invalidate();
|
|
||||||
},
|
},
|
||||||
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateOverride = useMutation({
|
const updateOverride = useMutation<
|
||||||
mutationFn: ({
|
unknown,
|
||||||
id,
|
Error,
|
||||||
body,
|
{ id: number; body: PlanOverridePatchBody; force?: boolean },
|
||||||
force,
|
CellRollback
|
||||||
}: {
|
>({
|
||||||
id: number;
|
mutationFn: ({ id, body, force }) =>
|
||||||
body: any;
|
|
||||||
force?: boolean;
|
|
||||||
}) =>
|
|
||||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
onSuccess: (_data, vars) => {
|
onMutate: (vars): CellRollback => {
|
||||||
// Find the user/date for this overrideId in the current grid, then
|
// Find the user/date for this overrideId in the current grid, then
|
||||||
// patch that single cell with the new values.
|
// patch that single cell with the new values.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||||
@@ -440,8 +528,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
date,
|
date,
|
||||||
projectId:
|
projectId:
|
||||||
vars.body.project_id ?? existing?.project_id ?? null,
|
vars.body.project_id ?? existing?.project_id ?? null,
|
||||||
category:
|
category: vars.body.category ?? existing?.category ?? "work",
|
||||||
vars.body.category ?? existing?.category ?? "work",
|
|
||||||
note: vars.body.note ?? existing?.note ?? "",
|
note: vars.body.note ?? existing?.note ?? "",
|
||||||
overrideId: vars.id,
|
overrideId: vars.id,
|
||||||
});
|
});
|
||||||
@@ -451,24 +538,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
(updateOverride as any)._rolled = rolled;
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
invalidate();
|
|
||||||
},
|
},
|
||||||
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteOverride = useMutation({
|
const deleteOverride = useMutation<
|
||||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
unknown,
|
||||||
|
Error,
|
||||||
|
{ id: number; force?: boolean },
|
||||||
|
CellRollback
|
||||||
|
>({
|
||||||
|
mutationFn: ({ id, force }) =>
|
||||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
onSuccess: (_data, vars) => {
|
onMutate: (vars): CellRollback => {
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||||
@@ -478,18 +570,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
[date],
|
[date],
|
||||||
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||||
);
|
);
|
||||||
(deleteOverride as any)._rolled = rolled;
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
invalidate();
|
|
||||||
},
|
},
|
||||||
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bulkCreate = useMutation({
|
const bulkCreate = useMutation<unknown, Error, BulkCreateBody>({
|
||||||
mutationFn: (body: any) =>
|
mutationFn: (body: BulkCreateBody) =>
|
||||||
apiCall("/api/admin/plan/entries/bulk", {
|
apiCall("/api/admin/plan/entries/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -502,29 +594,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Roll back an optimistic patch on mutation error. Called from
|
// Rollback is now handled idiomatically inside each mutation's `onError`
|
||||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
// from its `onMutate`-returned context (concurrency-safe per-invocation
|
||||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
// snapshot). This is kept as a no-op for the existing call sites in
|
||||||
// on it (see the `(createEntry as any)._rolled = …` writes above). Typed as
|
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
||||||
// `unknown` + an explicit cast so callers can pass the mutation object
|
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
// restored the patched cells, so there is nothing left to do here.
|
||||||
function rollbackMutation(mutation: unknown, userId: number) {
|
|
||||||
const stash = mutation as {
|
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||||
_rolled?: Record<string, ResolvedCell[]> | null;
|
/* no-op — see onMutate/onError above */
|
||||||
};
|
|
||||||
if (!stash._rolled) return;
|
|
||||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
|
||||||
if (!prev) return;
|
|
||||||
const userPrev = prev.cells[userId] ?? {};
|
|
||||||
const userNext = { ...userPrev };
|
|
||||||
for (const [date, cells] of Object.entries(stash._rolled)) {
|
|
||||||
userNext[date] = cells;
|
|
||||||
}
|
|
||||||
qc.setQueryData(currentGridKey, {
|
|
||||||
...prev,
|
|
||||||
cells: { ...prev.cells, [userId]: userNext },
|
|
||||||
});
|
|
||||||
stash._rolled = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,16 +4,22 @@ import { useEffect, useState } from "react";
|
|||||||
* Returns true when the user has expressed a preference for reduced motion
|
* Returns true when the user has expressed a preference for reduced motion
|
||||||
* (OS-level setting: `prefers-reduced-motion: reduce`).
|
* (OS-level setting: `prefers-reduced-motion: reduce`).
|
||||||
*
|
*
|
||||||
* SSR-safe: starts as `false` (the default state), so the first render uses
|
* SSR-safe: the lazy initializer reads the live matchMedia value on the client
|
||||||
* the normal animation. The effect then reconciles with the live matchMedia
|
* (and falls back to `false` when `window`/`matchMedia` is unavailable, e.g.
|
||||||
* state on the client and re-renders if needed.
|
* during SSR), so a reduced-motion user does NOT get one frame of animation
|
||||||
|
* before the effect reconciles. The effect then only listens for changes.
|
||||||
*/
|
*/
|
||||||
export default function useReducedMotion(): boolean {
|
export default function useReducedMotion(): boolean {
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(() => {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
// Reconcile in case the value changed between the lazy init and mount
|
||||||
|
// (or after SSR hydration).
|
||||||
setReduced(mq.matches);
|
setReduced(mq.matches);
|
||||||
const onChange = () => setReduced(mq.matches);
|
const onChange = () => setReduced(mq.matches);
|
||||||
mq.addEventListener("change", onChange);
|
mq.addEventListener("change", onChange);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import apiFetch from "../../utils/api";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
import { jsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
interface LocationRaw {
|
interface LocationRaw {
|
||||||
@@ -167,14 +166,15 @@ export const attendanceLocationOptions = (id: string | undefined) =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["attendance", "location", id],
|
queryKey: ["attendance", "location", id],
|
||||||
queryFn: async (): Promise<LocationRecord> => {
|
queryFn: async (): Promise<LocationRecord> => {
|
||||||
const response = await apiFetch(
|
// Route through jsonQuery so the response.ok / 401 / non-JSON cases are
|
||||||
|
// normalized into the same errors as every other options helper, then
|
||||||
|
// apply the location-specific shape transform on the unwrapped data.
|
||||||
|
const data = await jsonQuery<LocationRaw | { record: LocationRaw }>(
|
||||||
`/api/admin/attendance?action=location&id=${id}`,
|
`/api/admin/attendance?action=location&id=${id}`,
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
const raw = (
|
||||||
if (!result.success) {
|
"record" in data && data.record ? data.record : data
|
||||||
throw new Error(result.error || "Záznam nebyl nalezen");
|
) as LocationRaw;
|
||||||
}
|
|
||||||
const raw = (result.data.record || result.data) as LocationRaw;
|
|
||||||
const userName = raw.users
|
const userName = raw.users
|
||||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||||
: raw.user_name || "";
|
: raw.user_name || "";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import apiFetch from "../../utils/api";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
import { jsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
export const dashboardOptions = () =>
|
export const dashboardOptions = () =>
|
||||||
@@ -28,11 +27,12 @@ export const sessionsOptions = () =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["sessions"],
|
queryKey: ["sessions"],
|
||||||
queryFn: async (): Promise<Session[]> => {
|
queryFn: async (): Promise<Session[]> => {
|
||||||
const response = await apiFetch("/api/admin/sessions");
|
// Route through jsonQuery for the shared response.ok / 401 / non-JSON
|
||||||
const data = await response.json();
|
// guards, then normalize the two possible payload shapes (a bare array
|
||||||
if (data.success) {
|
// or `{ sessions: [...] }`).
|
||||||
return Array.isArray(data.data) ? data.data : data.data?.sessions || [];
|
const data = await jsonQuery<Session[] | { sessions?: Session[] }>(
|
||||||
}
|
"/api/admin/sessions",
|
||||||
throw new Error(data.error || "Nepodařilo se načíst relace");
|
);
|
||||||
|
return Array.isArray(data) ? data : (data.sessions ?? []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/leave-requests`,
|
url: () => `${API_BASE}/leave-requests`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "leave-requests", "users"],
|
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -323,6 +323,10 @@ export default function Attendance() {
|
|||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const latestActionRef = useRef<string | null>(null);
|
const latestActionRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
|
||||||
|
// actually advances instead of freezing at first paint.
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -332,6 +336,11 @@ export default function Attendance() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync notes from query data when the shift changes
|
// Sync notes from query data when the shift changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusQuery.data) {
|
if (statusQuery.data) {
|
||||||
@@ -475,10 +484,20 @@ export default function Attendance() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
|
||||||
|
// would parse as UTC midnight; reading it back with local getDay()/getDate()
|
||||||
|
// can land on the wrong calendar day in the evening Prague window.
|
||||||
|
const parseLocalDate = (s: string): Date | null => {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||||
|
};
|
||||||
|
|
||||||
const calculateBusinessDays = (from: string, to: string) => {
|
const calculateBusinessDays = (from: string, to: string) => {
|
||||||
if (!from || !to) return 0;
|
if (!from || !to) return 0;
|
||||||
const start = new Date(from);
|
const start = parseLocalDate(from);
|
||||||
const end = new Date(to);
|
const end = parseLocalDate(to);
|
||||||
|
if (!start || !end) return 0;
|
||||||
if (end < start) return 0;
|
if (end < start) return 0;
|
||||||
let days = 0;
|
let days = 0;
|
||||||
const current = new Date(start);
|
const current = new Date(start);
|
||||||
@@ -673,7 +692,7 @@ export default function Attendance() {
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{new Date().toLocaleTimeString("cs-CZ", {
|
{now.toLocaleTimeString("cs-CZ", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -168,8 +168,6 @@ export default function AttendanceBalances() {
|
|||||||
userName: string;
|
userName: string;
|
||||||
}>({ show: false, userId: null, userName: "" });
|
}>({ show: false, userId: null, userName: "" });
|
||||||
|
|
||||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
|
||||||
|
|
||||||
const editMutation = useApiMutation<
|
const editMutation = useApiMutation<
|
||||||
{
|
{
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -199,6 +197,8 @@ export default function AttendanceBalances() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||||
|
|
||||||
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||||||
setEditingUser({ id: userId, name: balance.name });
|
setEditingUser({ id: userId, name: balance.name });
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -767,7 +767,7 @@ export default function AttendanceBalances() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({
|
||||||
...editForm,
|
...editForm,
|
||||||
vacation_total: parseFloat(e.target.value),
|
vacation_total: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
|
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
|
||||||
@@ -781,7 +781,7 @@ export default function AttendanceBalances() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({
|
||||||
...editForm,
|
...editForm,
|
||||||
vacation_used: parseFloat(e.target.value),
|
vacation_used: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||||
@@ -795,7 +795,7 @@ export default function AttendanceBalances() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({
|
||||||
...editForm,
|
...editForm,
|
||||||
sick_used: parseFloat(e.target.value),
|
sick_used: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function AttendanceCreate() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
leave_hours: parseFloat(e.target.value),
|
leave_hours: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||||
|
|||||||
@@ -184,6 +184,13 @@ export default function AttendanceLocation() {
|
|||||||
|
|
||||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||||
|
|
||||||
|
// Show the spinner while the record is still loading — this must precede the
|
||||||
|
// `!record` null-return below, otherwise a pending query (record === undefined)
|
||||||
|
// renders a blank page and this branch is dead.
|
||||||
|
if (isPending) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -196,10 +203,6 @@ export default function AttendanceLocation() {
|
|||||||
: record.shift_date;
|
: record.shift_date;
|
||||||
const month = shiftDateStr.substring(0, 7);
|
const month = shiftDateStr.substring(0, 7);
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageEnter>
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -182,10 +182,6 @@ export default function AuditLog() {
|
|||||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||||
const pagination = logsData?.pagination ?? null;
|
const pagination = logsData?.pagination ?? null;
|
||||||
|
|
||||||
if (!hasPermission("settings.audit")) {
|
|
||||||
return <Forbidden />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanupMutation = useApiMutation<
|
const cleanupMutation = useApiMutation<
|
||||||
{ days: number; confirm?: string },
|
{ days: number; confirm?: string },
|
||||||
{ message?: string; error?: string }
|
{ message?: string; error?: string }
|
||||||
@@ -199,6 +195,10 @@ export default function AuditLog() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("settings.audit")) {
|
||||||
|
return <Forbidden />;
|
||||||
|
}
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
|||||||
@@ -1309,7 +1309,7 @@ export default function CompanySettings({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField("default_vat_rate", Number(e.target.value))
|
updateField("default_vat_rate", Number(e.target.value))
|
||||||
}
|
}
|
||||||
inputProps={{ min: 0, step: 1 }}
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Dostupné měny">
|
<Field label="Dostupné měny">
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { offerCustomersOptions, type Customer } 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, todayLocalStr } from "../utils/formatters";
|
||||||
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -69,6 +70,33 @@ import {
|
|||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
|
||||||
|
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
|
||||||
|
* used. We build the Date from the local year/month/day parts (so it's a local
|
||||||
|
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
|
||||||
|
* it back with local getters — never via `toISOString()`, which would shift a
|
||||||
|
* day during the evening Prague window. See utils/formatters.todayLocalStr.
|
||||||
|
*/
|
||||||
|
function addDaysLocalStr(days: number, base?: string): string {
|
||||||
|
let y: number, m: number, d: number;
|
||||||
|
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
|
||||||
|
if (parts) {
|
||||||
|
y = Number(parts[1]);
|
||||||
|
m = Number(parts[2]) - 1;
|
||||||
|
d = Number(parts[3]);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
y = now.getFullYear();
|
||||||
|
m = now.getMonth();
|
||||||
|
d = now.getDate();
|
||||||
|
}
|
||||||
|
const result = new Date(y, m, d + days);
|
||||||
|
const mm = String(result.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(result.getDate()).padStart(2, "0");
|
||||||
|
return `${result.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
issued: "Vystavena",
|
issued: "Vystavena",
|
||||||
paid: "Zaplacena",
|
paid: "Zaplacena",
|
||||||
@@ -491,7 +519,7 @@ export default function InvoiceDetail() {
|
|||||||
customer_name: "",
|
customer_name: "",
|
||||||
order_id: fromOrderId ? Number(fromOrderId) : null,
|
order_id: fromOrderId ? Number(fromOrderId) : null,
|
||||||
issue_date: todayLocalStr(),
|
issue_date: todayLocalStr(),
|
||||||
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
|
due_date: addDaysLocalStr(14),
|
||||||
tax_date: todayLocalStr(),
|
tax_date: todayLocalStr(),
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
apply_vat: 1,
|
apply_vat: 1,
|
||||||
@@ -641,15 +669,9 @@ export default function InvoiceDetail() {
|
|||||||
customer_id: inv.customer_id || null,
|
customer_id: inv.customer_id || null,
|
||||||
customer_name: inv.customer_name || "",
|
customer_name: inv.customer_name || "",
|
||||||
order_id: inv.order_id || null,
|
order_id: inv.order_id || null,
|
||||||
issue_date: inv.issue_date
|
issue_date: normalizeDateStr(inv.issue_date),
|
||||||
? new Date(inv.issue_date).toISOString().split("T")[0]
|
due_date: normalizeDateStr(inv.due_date),
|
||||||
: "",
|
tax_date: normalizeDateStr(inv.tax_date),
|
||||||
due_date: inv.due_date
|
|
||||||
? new Date(inv.due_date).toISOString().split("T")[0]
|
|
||||||
: "",
|
|
||||||
tax_date: inv.tax_date
|
|
||||||
? new Date(inv.tax_date).toISOString().split("T")[0]
|
|
||||||
: "",
|
|
||||||
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: Number(inv.vat_rate) || 21,
|
||||||
@@ -713,7 +735,7 @@ export default function InvoiceDetail() {
|
|||||||
bankAccountsQuery.isLoading,
|
bankAccountsQuery.isLoading,
|
||||||
bankAccountsQuery.data,
|
bankAccountsQuery.data,
|
||||||
customersQuery.isLoading,
|
customersQuery.isLoading,
|
||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]);
|
||||||
|
|
||||||
// Create mode: populate form from query data
|
// Create mode: populate form from query data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -791,7 +813,7 @@ export default function InvoiceDetail() {
|
|||||||
orderDataQuery.data,
|
orderDataQuery.data,
|
||||||
companySettings,
|
companySettings,
|
||||||
bankAccounts,
|
bankAccounts,
|
||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]);
|
||||||
|
|
||||||
// Capture initial snapshot for dirty-checking once data sync completes.
|
// Capture initial snapshot for dirty-checking once data sync completes.
|
||||||
// Edit mode: captured inside the sync effect from raw query data.
|
// Edit mode: captured inside the sync effect from raw query data.
|
||||||
@@ -817,9 +839,7 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
const computedDueDate = useMemo(() => {
|
const computedDueDate = useMemo(() => {
|
||||||
if (!form.issue_date) return "";
|
if (!form.issue_date) return "";
|
||||||
const d = new Date(form.issue_date);
|
return addDaysLocalStr(dueDays, form.issue_date);
|
||||||
d.setDate(d.getDate() + dueDays);
|
|
||||||
return d.toISOString().split("T")[0];
|
|
||||||
}, [form.issue_date, dueDays]);
|
}, [form.issue_date, dueDays]);
|
||||||
|
|
||||||
// ─── Create mode: customer filtering ───
|
// ─── Create mode: customer filtering ───
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import CircularProgress from "@mui/material/CircularProgress";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
czechPlural,
|
||||||
|
todayLocalStr,
|
||||||
|
} from "../utils/formatters";
|
||||||
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import {
|
import {
|
||||||
@@ -499,12 +505,14 @@ export default function Invoices() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Per-row overdue tint (replaces offers-expired-row).
|
// Per-row overdue tint (replaces offers-expired-row).
|
||||||
|
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
|
||||||
|
// to avoid the UTC-vs-local off-by-one near midnight Prague.
|
||||||
const rowSx = (inv: Invoice) => {
|
const rowSx = (inv: Invoice) => {
|
||||||
const isOverdue =
|
const isOverdue =
|
||||||
inv.status === "overdue" ||
|
inv.status === "overdue" ||
|
||||||
(inv.status === "issued" &&
|
(inv.status === "issued" &&
|
||||||
inv.due_date &&
|
!!inv.due_date &&
|
||||||
new Date(inv.due_date) < new Date(new Date().toDateString()));
|
normalizeDateStr(inv.due_date) < todayLocalStr());
|
||||||
if (isOverdue) {
|
if (isOverdue) {
|
||||||
return {
|
return {
|
||||||
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||||||
|
|||||||
@@ -155,8 +155,6 @@ export default function LeaveApproval() {
|
|||||||
}>({ open: false, request: null });
|
}>({ open: false, request: null });
|
||||||
const [rejectNote, setRejectNote] = useState("");
|
const [rejectNote, setRejectNote] = useState("");
|
||||||
|
|
||||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
|
||||||
|
|
||||||
const approveMutation = useApiMutation<
|
const approveMutation = useApiMutation<
|
||||||
{ id: number; status: "approved" },
|
{ id: number; status: "approved" },
|
||||||
unknown
|
unknown
|
||||||
@@ -184,6 +182,8 @@ export default function LeaveApproval() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||||
|
|
||||||
const handleApprove = async () => {
|
const handleApprove = async () => {
|
||||||
if (!approveModal.request) return;
|
if (!approveModal.request) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -99,10 +99,20 @@ export default function Login() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!totpCode.trim()) return;
|
if (!totpCode.trim()) return;
|
||||||
|
|
||||||
|
// Defensive: the 2FA form only renders after a loginToken is issued, but
|
||||||
|
// guard against a null token (e.g. server returns requires2FA without one)
|
||||||
|
// rather than passing `null!` through to verify2FA.
|
||||||
|
if (!loginToken) {
|
||||||
|
alert.error("Relace vypršela, přihlaste se prosím znovu");
|
||||||
|
setShow2FA(false);
|
||||||
|
setTotpCode("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const result = await verify2FA(
|
const result = await verify2FA(
|
||||||
loginToken!,
|
loginToken,
|
||||||
totpCode.trim(),
|
totpCode.trim(),
|
||||||
remember,
|
remember,
|
||||||
useBackupCode,
|
useBackupCode,
|
||||||
@@ -131,6 +141,7 @@ export default function Login() {
|
|||||||
setLoading,
|
setLoading,
|
||||||
setShake,
|
setShake,
|
||||||
setTotpCode,
|
setTotpCode,
|
||||||
|
setShow2FA,
|
||||||
setAnimatingOut,
|
setAnimatingOut,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ export default function OfferDetail() {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
}, [isEdit, id, isLockedByOther, isInvalidated]);
|
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
|
||||||
|
|
||||||
// Capture initial snapshot after loading completes (create mode)
|
// Capture initial snapshot after loading completes (create mode)
|
||||||
if (!loading && !initialSnapshotRef.current) {
|
if (!loading && !initialSnapshotRef.current) {
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export default function OffersCustomers() {
|
|||||||
|
|
||||||
return order.filter((key) => {
|
return order.filter((key) => {
|
||||||
if (key.startsWith("custom_")) {
|
if (key.startsWith("custom_")) {
|
||||||
const idx = parseInt(key.split("_")[1]);
|
const idx = parseInt(key.split("_")[1], 10);
|
||||||
return idx < customFields.length;
|
return idx < customFields.length;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -255,7 +255,7 @@ export default function OffersCustomers() {
|
|||||||
const getFieldDisplayName = (key: string) => {
|
const getFieldDisplayName = (key: string) => {
|
||||||
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key];
|
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key];
|
||||||
if (key.startsWith("custom_")) {
|
if (key.startsWith("custom_")) {
|
||||||
const idx = parseInt(key.split("_")[1]);
|
const idx = parseInt(key.split("_")[1], 10);
|
||||||
const cf = customFields[idx];
|
const cf = customFields[idx];
|
||||||
if (cf)
|
if (cf)
|
||||||
return cf.name
|
return cf.name
|
||||||
@@ -672,7 +672,7 @@ export default function OffersCustomers() {
|
|||||||
.filter((k) => k !== key)
|
.filter((k) => k !== key)
|
||||||
.map((k) => {
|
.map((k) => {
|
||||||
if (k.startsWith("custom_")) {
|
if (k.startsWith("custom_")) {
|
||||||
const ki = parseInt(k.split("_")[1]);
|
const ki = parseInt(k.split("_")[1], 10);
|
||||||
if (ki > idx) return `custom_${ki - 1}`;
|
if (ki > idx) return `custom_${ki - 1}`;
|
||||||
}
|
}
|
||||||
return k;
|
return k;
|
||||||
|
|||||||
@@ -176,8 +176,6 @@ export default function OrderDetail() {
|
|||||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
||||||
}, [order]);
|
}, [order]);
|
||||||
|
|
||||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
|
||||||
|
|
||||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||||
url: () => `${API_BASE}/orders/${id}`,
|
url: () => `${API_BASE}/orders/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
@@ -199,6 +197,8 @@ export default function OrderDetail() {
|
|||||||
invalidate: ["orders", "invoices"],
|
invalidate: ["orders", "invoices"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||||
|
|
||||||
const handleStatusChange = async () => {
|
const handleStatusChange = async () => {
|
||||||
if (!statusConfirm.status) return;
|
if (!statusConfirm.status) return;
|
||||||
const newStatus = statusConfirm.status;
|
const newStatus = statusConfirm.status;
|
||||||
@@ -462,8 +462,8 @@ export default function OrderDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission("orders.edit") &&
|
{hasPermission("orders.edit") &&
|
||||||
order.valid_transitions?.filter((s) => s !== "stornovana")
|
(order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||||
.length! > 0 &&
|
.length ?? 0) > 0 &&
|
||||||
order
|
order
|
||||||
.valid_transitions!.filter((s) => s !== "stornovana")
|
.valid_transitions!.filter((s) => s !== "stornovana")
|
||||||
.map((status) => (
|
.map((status) => (
|
||||||
|
|||||||
@@ -174,8 +174,6 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
|
||||||
|
|
||||||
const projectSaveMutation = useApiMutation<
|
const projectSaveMutation = useApiMutation<
|
||||||
{
|
{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -212,6 +210,8 @@ export default function ProjectDetail() {
|
|||||||
invalidate: ["projects", "warehouse"],
|
invalidate: ["projects", "warehouse"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||||
|
|
||||||
const updateForm = (field: keyof ProjectForm, value: string) =>
|
const updateForm = (field: keyof ProjectForm, value: string) =>
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import {
|
import {
|
||||||
projectListOptions,
|
projectListOptions,
|
||||||
@@ -117,6 +118,7 @@ export default function Projects() {
|
|||||||
|
|
||||||
const { sort, order, handleSort } = useTableSort("project_number");
|
const { sort, order, handleSort } = useTableSort("project_number");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||||
@@ -216,7 +218,7 @@ export default function Projects() {
|
|||||||
isPending,
|
isPending,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = usePaginatedQuery<Project>(
|
} = usePaginatedQuery<Project>(
|
||||||
projectListOptions({ search, sort, order, page }),
|
projectListOptions({ search: debouncedSearch, sort, order, page }),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAlert } from "../context/AlertContext";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import {
|
import {
|
||||||
companySettingsOptions,
|
companySettingsOptions,
|
||||||
@@ -276,7 +277,14 @@ export default function ReceivedInvoices({
|
|||||||
|
|
||||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||||
const invoices = listQuery.data?.data ?? [];
|
const invoices = listQuery.data?.data ?? [];
|
||||||
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
|
|
||||||
|
// Track first successful load (used to suppress the skeleton on later
|
||||||
|
// refetches). Set in an effect rather than during render to avoid a
|
||||||
|
// render-phase ref mutation.
|
||||||
|
const hasData = !!(listQuery.data || statsQuery.data);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasData) hasLoadedOnce.current = true;
|
||||||
|
}, [hasData]);
|
||||||
|
|
||||||
// Derive stats from query
|
// Derive stats from query
|
||||||
const stats = statsQuery.data ?? null;
|
const stats = statsQuery.data ?? null;
|
||||||
@@ -446,12 +454,11 @@ export default function ReceivedInvoices({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toDateInput = (d: string | null | undefined): string => {
|
// Seed edit-form date inputs from stored values. Use normalizeDateStr (pure
|
||||||
if (!d) return "";
|
// string slice) instead of new Date(...).toISOString() — the latter converts
|
||||||
const date = new Date(d);
|
// to UTC and is a day early in the late-evening Prague window.
|
||||||
if (isNaN(date.getTime())) return "";
|
const toDateInput = (d: string | null | undefined): string =>
|
||||||
return date.toISOString().split("T")[0];
|
normalizeDateStr(d);
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (inv: ReceivedInvoice) => {
|
const openEdit = (inv: ReceivedInvoice) => {
|
||||||
setEditInvoice({
|
setEditInvoice({
|
||||||
|
|||||||
@@ -311,11 +311,6 @@ export default function Settings() {
|
|||||||
setSysFormInitialized(true);
|
setSysFormInitialized(true);
|
||||||
}, [sysSettingsData, sysFormInitialized]);
|
}, [sysSettingsData, sysFormInitialized]);
|
||||||
|
|
||||||
// ── Early return after all hooks ──
|
|
||||||
if (!canAccessSettings) {
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSystemSettingsMutation = useApiMutation<
|
const saveSystemSettingsMutation = useApiMutation<
|
||||||
typeof sysForm,
|
typeof sysForm,
|
||||||
{ message?: string; error?: string }
|
{ message?: string; error?: string }
|
||||||
@@ -400,6 +395,11 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Early return after all hooks ──
|
||||||
|
if (!canAccessSettings) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveSystemSettings = async () => {
|
const handleSaveSystemSettings = async () => {
|
||||||
try {
|
try {
|
||||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||||
|
|||||||
@@ -185,8 +185,6 @@ export default function Trips() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [, setLastKm] = useState(0);
|
const [, setLastKm] = useState(0);
|
||||||
|
|
||||||
if (!hasPermission("trips.record")) return <Forbidden />;
|
|
||||||
|
|
||||||
const submitMutation = useApiMutation<
|
const submitMutation = useApiMutation<
|
||||||
TripForm,
|
TripForm,
|
||||||
{ message?: string; error?: string }
|
{ message?: string; error?: string }
|
||||||
@@ -214,6 +212,8 @@ export default function Trips() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("trips.record")) return <Forbidden />;
|
||||||
|
|
||||||
const fetchLastKm = async (vehicleId: string) => {
|
const fetchLastKm = async (vehicleId: string) => {
|
||||||
if (!vehicleId) {
|
if (!vehicleId) {
|
||||||
setLastKm(0);
|
setLastKm(0);
|
||||||
@@ -284,7 +284,7 @@ export default function Trips() {
|
|||||||
if (
|
if (
|
||||||
form.start_km &&
|
form.start_km &&
|
||||||
form.end_km &&
|
form.end_km &&
|
||||||
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
|
parseInt(String(form.end_km), 10) <= parseInt(String(form.start_km), 10)
|
||||||
) {
|
) {
|
||||||
newErrors.end_km = "Musí být větší než počáteční";
|
newErrors.end_km = "Musí být větší než počáteční";
|
||||||
}
|
}
|
||||||
@@ -312,8 +312,8 @@ export default function Trips() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculateDistance = (): number => {
|
const calculateDistance = (): number => {
|
||||||
const start = parseInt(String(form.start_km)) || 0;
|
const start = parseInt(String(form.start_km), 10) || 0;
|
||||||
const end = parseInt(String(form.end_km)) || 0;
|
const end = parseInt(String(form.end_km), 10) || 0;
|
||||||
return end > start ? end - start : 0;
|
return end > start ? end - start : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ export default function TripsAdmin() {
|
|||||||
);
|
);
|
||||||
const trips = (tripsData ?? []).map(mapTrip);
|
const trips = (tripsData ?? []).map(mapTrip);
|
||||||
|
|
||||||
if (!hasPermission("trips.manage")) return <Forbidden />;
|
|
||||||
|
|
||||||
// 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 —
|
||||||
// NOT a wrapper that nests the body. id is captured in the URL closure.
|
// NOT a wrapper that nests the body. id is captured in the URL closure.
|
||||||
@@ -265,6 +263,8 @@ export default function TripsAdmin() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||||
|
|
||||||
const openEditModal = (trip: Trip) => {
|
const openEditModal = (trip: Trip) => {
|
||||||
setEditingTrip(trip);
|
setEditingTrip(trip);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 Chip from "@mui/material/Chip";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
SwitchField,
|
SwitchField,
|
||||||
|
StatusChip,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
type DataColumn,
|
type DataColumn,
|
||||||
@@ -261,12 +261,10 @@ export default function Vehicles() {
|
|||||||
key: "status",
|
key: "status",
|
||||||
header: "Stav",
|
header: "Stav",
|
||||||
render: (v) => (
|
render: (v) => (
|
||||||
<Chip
|
<StatusChip
|
||||||
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
size="small"
|
|
||||||
color={v.is_active ? "success" : "default"}
|
color={v.is_active ? "success" : "default"}
|
||||||
onClick={() => toggleActive(v)}
|
onClick={() => toggleActive(v)}
|
||||||
sx={{ cursor: "pointer", fontWeight: 600 }}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -401,7 +399,10 @@ export default function Vehicles() {
|
|||||||
value={form.initial_km}
|
value={form.initial_km}
|
||||||
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
setForm({
|
||||||
|
...form,
|
||||||
|
initial_km: parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
warehouseReservationListOptions,
|
warehouseReservationListOptions,
|
||||||
warehouseMovementLogOptions,
|
warehouseMovementLogOptions,
|
||||||
type WarehouseItem,
|
type WarehouseItem,
|
||||||
|
type MovementLogRow,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
@@ -72,15 +73,8 @@ interface BelowMinRow {
|
|||||||
min_quantity: number;
|
min_quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovementRow {
|
// Dashboard movement row = the shared lib row plus a stable list index for rowKey.
|
||||||
_idx: number;
|
type MovementRow = MovementLogRow & { _idx: number };
|
||||||
date: string;
|
|
||||||
type: string;
|
|
||||||
document_number: string;
|
|
||||||
item_name: string;
|
|
||||||
quantity: number;
|
|
||||||
supplier_or_project: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Warehouse() {
|
export default function Warehouse() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export default function WarehouseCategories() {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
category: WarehouseCategory | null;
|
category: WarehouseCategory | null;
|
||||||
}>({ show: false, category: null });
|
}>({ show: false, category: null });
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const submitMutation = useApiMutation<CategoryForm, void>({
|
const submitMutation = useApiMutation<CategoryForm, void>({
|
||||||
url: () =>
|
url: () =>
|
||||||
@@ -156,13 +155,10 @@ export default function WarehouseCategories() {
|
|||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
try {
|
||||||
await submitMutation.mutateAsync(form);
|
await submitMutation.mutateAsync(form);
|
||||||
} 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 {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,7 +270,7 @@ export default function WarehouseCategories() {
|
|||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
||||||
loading={saving}
|
loading={submitMutation.isPending}
|
||||||
>
|
>
|
||||||
<Field label="Název" required error={errors.name}>
|
<Field label="Název" required error={errors.name}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -302,12 +298,13 @@ export default function WarehouseCategories() {
|
|||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={String(form.sort_order)}
|
value={String(form.sort_order)}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
sort_order: parseInt(e.target.value) || 0,
|
sort_order: Number.isFinite(n) ? n : 0,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
inputProps={{ min: 0 }}
|
inputProps={{ min: 0 }}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -122,7 +122,12 @@ export default function WarehouseInventory() {
|
|||||||
header: "Položky",
|
header: "Položky",
|
||||||
width: "20%",
|
width: "20%",
|
||||||
mono: true,
|
mono: true,
|
||||||
render: (s) => String(s.items?.length ?? 0),
|
// List endpoint returns `_count` (not `items`), matching the issues/receipts lists.
|
||||||
|
render: (s) =>
|
||||||
|
String(
|
||||||
|
(s as WarehouseInventorySession & { _count?: { items: number } })
|
||||||
|
._count?.items ?? 0,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -67,13 +67,9 @@ export default function WarehouseInventoryDetail() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery(warehouseInventoryDetailOptions(id));
|
} = useQuery(warehouseInventoryDetailOptions(id));
|
||||||
|
|
||||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
const confirmMutation = useApiMutation<number, void>({
|
||||||
|
url: (sessionId) =>
|
||||||
const confirmMutation = useApiMutation<void, void>({
|
`/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`,
|
||||||
url: () =>
|
|
||||||
session
|
|
||||||
? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`
|
|
||||||
: "/api/admin/warehouse/inventory-sessions/0/confirm",
|
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -82,10 +78,13 @@ export default function WarehouseInventoryDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
|
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await confirmMutation.mutateAsync(undefined);
|
await confirmMutation.mutateAsync(session.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface InventoryItem {
|
|||||||
actual_qty: number;
|
actual_qty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDecimal(raw: string): number {
|
||||||
|
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
const BackIcon = (
|
const BackIcon = (
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -80,17 +86,6 @@ export default function WarehouseInventoryForm() {
|
|||||||
|
|
||||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||||
|
|
||||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
|
||||||
|
|
||||||
const validate = (): boolean => {
|
|
||||||
const validItems = items.filter((l) => l.item_id !== null);
|
|
||||||
if (validItems.length === 0) {
|
|
||||||
alert.error("Přidejte alespoň jednu položku");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMutation = useApiMutation<
|
const createMutation = useApiMutation<
|
||||||
{
|
{
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -111,6 +106,24 @@ export default function WarehouseInventoryForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
|
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const validItems = items.filter((l) => l.item_id !== null);
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
alert.error("Přidejte alespoň jednu položku");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const l of validItems) {
|
||||||
|
if (!Number.isFinite(l.actual_qty) || l.actual_qty < 0) {
|
||||||
|
alert.error("Skutečné množství musí být platné číslo");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -246,7 +259,7 @@ export default function WarehouseInventoryForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={item.actual_qty || ""}
|
value={item.actual_qty || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(index, "actual_qty", Number(e.target.value))
|
updateItem(index, "actual_qty", parseDecimal(e.target.value))
|
||||||
}
|
}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|||||||
@@ -70,15 +70,8 @@ export default function WarehouseIssueDetail() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery(warehouseIssueDetailOptions(id));
|
} = useQuery(warehouseIssueDetailOptions(id));
|
||||||
|
|
||||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
const confirmMutation = useApiMutation<number, void>({
|
||||||
|
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||||
const canOperate = hasPermission("warehouse.operate");
|
|
||||||
|
|
||||||
const confirmMutation = useApiMutation<void, void>({
|
|
||||||
url: () =>
|
|
||||||
issue
|
|
||||||
? `/api/admin/warehouse/issues/${issue.id}/confirm`
|
|
||||||
: "/api/admin/warehouse/issues/0/confirm",
|
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -86,11 +79,8 @@ export default function WarehouseIssueDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelMutation = useApiMutation<void, void>({
|
const cancelMutation = useApiMutation<number, void>({
|
||||||
url: () =>
|
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`,
|
||||||
issue
|
|
||||||
? `/api/admin/warehouse/issues/${issue.id}/cancel`
|
|
||||||
: "/api/admin/warehouse/issues/0/cancel",
|
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -99,10 +89,15 @@ export default function WarehouseIssueDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
|
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||||
|
|
||||||
|
const canOperate = hasPermission("warehouse.operate");
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
try {
|
try {
|
||||||
await confirmMutation.mutateAsync(undefined);
|
await confirmMutation.mutateAsync(issue.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
@@ -111,7 +106,7 @@ export default function WarehouseIssueDetail() {
|
|||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
try {
|
try {
|
||||||
await cancelMutation.mutateAsync(undefined);
|
await cancelMutation.mutateAsync(issue.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
@@ -315,8 +310,8 @@ export default function WarehouseIssueDetail() {
|
|||||||
{iss.project ? (
|
{iss.project ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
component="a"
|
component={RouterLink}
|
||||||
href={`/projects/${iss.project.id}`}
|
to={`/projects/${iss.project.id}`}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: "'DM Mono', Menlo, monospace",
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|||||||
@@ -264,6 +264,28 @@ export default function WarehouseIssueForm() {
|
|||||||
}
|
}
|
||||||
}, [isEdit, issue]);
|
}, [isEdit, issue]);
|
||||||
|
|
||||||
|
const saveMutation = useApiMutation<
|
||||||
|
ReturnType<typeof buildPayload>,
|
||||||
|
{ id?: number }
|
||||||
|
>({
|
||||||
|
url: () =>
|
||||||
|
isEdit
|
||||||
|
? `/api/admin/warehouse/issues/${id}`
|
||||||
|
: "/api/admin/warehouse/issues",
|
||||||
|
method: () => (isEdit ? "PUT" : "POST"),
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmMutation = useApiMutation<number, void>({
|
||||||
|
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||||
|
method: () => "POST",
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
onSuccess: () => {
|
||||||
|
alert.success("Výdejka byla potvrzena");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||||
|
|
||||||
const addItem = () => {
|
const addItem = () => {
|
||||||
@@ -332,31 +354,6 @@ export default function WarehouseIssueForm() {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useApiMutation<
|
|
||||||
ReturnType<typeof buildPayload>,
|
|
||||||
{ id?: number }
|
|
||||||
>({
|
|
||||||
url: () =>
|
|
||||||
isEdit
|
|
||||||
? `/api/admin/warehouse/issues/${id}`
|
|
||||||
: "/api/admin/warehouse/issues",
|
|
||||||
method: () => (isEdit ? "PUT" : "POST"),
|
|
||||||
invalidate: ["warehouse"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
|
|
||||||
const confirmMutation = useApiMutation<void, void>({
|
|
||||||
url: () =>
|
|
||||||
confirmIssueId
|
|
||||||
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
|
|
||||||
: "/api/admin/warehouse/issues/0/confirm",
|
|
||||||
method: () => "POST",
|
|
||||||
invalidate: ["warehouse"],
|
|
||||||
onSuccess: () => {
|
|
||||||
alert.success("Výdejka byla potvrzena");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveIssue = async (): Promise<number | null> => {
|
const saveIssue = async (): Promise<number | null> => {
|
||||||
try {
|
try {
|
||||||
const data = await saveMutation.mutateAsync(buildPayload());
|
const data = await saveMutation.mutateAsync(buildPayload());
|
||||||
@@ -370,13 +367,10 @@ export default function WarehouseIssueForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmIssue = async (issueId: number) => {
|
const confirmIssue = async (issueId: number) => {
|
||||||
setConfirmIssueId(issueId);
|
|
||||||
try {
|
try {
|
||||||
await confirmMutation.mutateAsync(undefined);
|
await confirmMutation.mutateAsync(issueId);
|
||||||
} 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 {
|
|
||||||
setConfirmIssueId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -154,13 +154,6 @@ export default function WarehouseItemDetail() {
|
|||||||
|
|
||||||
useModalLock(editing);
|
useModalLock(editing);
|
||||||
|
|
||||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
|
||||||
|
|
||||||
const canManage = hasPermission("warehouse.manage");
|
|
||||||
|
|
||||||
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
|
|
||||||
const saveMutation = useApiMutation<
|
const saveMutation = useApiMutation<
|
||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
{ id?: number; message?: string }
|
{ id?: number; message?: string }
|
||||||
@@ -187,6 +180,14 @@ export default function WarehouseItemDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
|
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||||
|
|
||||||
|
const canManage = hasPermission("warehouse.manage");
|
||||||
|
|
||||||
|
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
|
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
|
||||||
|
|||||||
@@ -233,13 +233,8 @@ export default function WarehouseLocations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = locations.length;
|
const total = locations.length;
|
||||||
const subtitle = `${total} ${
|
// "umístění" is invariant across grammatical number in Czech — no plural form.
|
||||||
total === 1
|
const subtitle = `${total} umístění`;
|
||||||
? "umístění"
|
|
||||||
: total >= 2 && total <= 4
|
|
||||||
? "umístění"
|
|
||||||
: "umístění"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const columns: DataColumn<WarehouseLocation>[] = [
|
const columns: DataColumn<WarehouseLocation>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,15 +94,8 @@ export default function WarehouseReceiptDetail() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery(warehouseReceiptDetailOptions(id));
|
} = useQuery(warehouseReceiptDetailOptions(id));
|
||||||
|
|
||||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
const confirmMutation = useApiMutation<number, void>({
|
||||||
|
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||||
const canOperate = hasPermission("warehouse.operate");
|
|
||||||
|
|
||||||
const confirmMutation = useApiMutation<void, void>({
|
|
||||||
url: () =>
|
|
||||||
receipt
|
|
||||||
? `/api/admin/warehouse/receipts/${receipt.id}/confirm`
|
|
||||||
: "/api/admin/warehouse/receipts/0/confirm",
|
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -110,11 +103,8 @@ export default function WarehouseReceiptDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelMutation = useApiMutation<void, void>({
|
const cancelMutation = useApiMutation<number, void>({
|
||||||
url: () =>
|
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/cancel`,
|
||||||
receipt
|
|
||||||
? `/api/admin/warehouse/receipts/${receipt.id}/cancel`
|
|
||||||
: "/api/admin/warehouse/receipts/0/cancel",
|
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -124,13 +114,11 @@ export default function WarehouseReceiptDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteAttachmentMutation = useApiMutation<
|
const deleteAttachmentMutation = useApiMutation<
|
||||||
{ attachmentId: number },
|
{ receiptId: number; attachmentId: number },
|
||||||
void
|
void
|
||||||
>({
|
>({
|
||||||
url: ({ attachmentId }) =>
|
url: ({ receiptId, attachmentId }) =>
|
||||||
receipt
|
`/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||||
? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`
|
|
||||||
: `/api/admin/warehouse/receipts/0/attachments/0`,
|
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -138,10 +126,15 @@ export default function WarehouseReceiptDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
|
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||||
|
|
||||||
|
const canOperate = hasPermission("warehouse.operate");
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!receipt) return;
|
if (!receipt) return;
|
||||||
try {
|
try {
|
||||||
await confirmMutation.mutateAsync(undefined);
|
await confirmMutation.mutateAsync(receipt.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
@@ -150,7 +143,7 @@ export default function WarehouseReceiptDetail() {
|
|||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (!receipt) return;
|
if (!receipt) return;
|
||||||
try {
|
try {
|
||||||
await cancelMutation.mutateAsync(undefined);
|
await cancelMutation.mutateAsync(receipt.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
@@ -159,7 +152,10 @@ export default function WarehouseReceiptDetail() {
|
|||||||
const handleDeleteAttachment = async (attachmentId: number) => {
|
const handleDeleteAttachment = async (attachmentId: number) => {
|
||||||
if (!receipt) return;
|
if (!receipt) return;
|
||||||
try {
|
try {
|
||||||
await deleteAttachmentMutation.mutateAsync({ attachmentId });
|
await deleteAttachmentMutation.mutateAsync({
|
||||||
|
receiptId: receipt.id,
|
||||||
|
attachmentId,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,75 @@ export default function WarehouseReceiptForm() {
|
|||||||
}
|
}
|
||||||
}, [isEdit, receipt]);
|
}, [isEdit, receipt]);
|
||||||
|
|
||||||
|
const saveMutation = useApiMutation<
|
||||||
|
ReturnType<typeof buildPayload>,
|
||||||
|
{ id?: number }
|
||||||
|
>({
|
||||||
|
url: () =>
|
||||||
|
isEdit
|
||||||
|
? `/api/admin/warehouse/receipts/${id}`
|
||||||
|
: "/api/admin/warehouse/receipts",
|
||||||
|
method: () => (isEdit ? "PUT" : "POST"),
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmMutation = useApiMutation<number, void>({
|
||||||
|
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||||
|
method: () => "POST",
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
onSuccess: () => {
|
||||||
|
alert.success("Doklad byl potvrzen");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(
|
||||||
|
async (files: FileList | File[]) => {
|
||||||
|
if (!isEdit || !id) return;
|
||||||
|
setUploadingFiles(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/admin/warehouse/receipts/${id}/attachments`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
alert.error(
|
||||||
|
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||||
|
alert.success("Soubory byly nahrány");
|
||||||
|
} catch {
|
||||||
|
alert.error("Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setUploadingFiles(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, isEdit, alert, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
handleFileUpload(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFileUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||||
|
|
||||||
const addItem = () => {
|
const addItem = () => {
|
||||||
@@ -266,31 +335,6 @@ export default function WarehouseReceiptForm() {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useApiMutation<
|
|
||||||
ReturnType<typeof buildPayload>,
|
|
||||||
{ id?: number }
|
|
||||||
>({
|
|
||||||
url: () =>
|
|
||||||
isEdit
|
|
||||||
? `/api/admin/warehouse/receipts/${id}`
|
|
||||||
: "/api/admin/warehouse/receipts",
|
|
||||||
method: () => (isEdit ? "PUT" : "POST"),
|
|
||||||
invalidate: ["warehouse"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [confirmReceiptId, setConfirmReceiptId] = useState<number | null>(null);
|
|
||||||
const confirmMutation = useApiMutation<void, void>({
|
|
||||||
url: () =>
|
|
||||||
confirmReceiptId
|
|
||||||
? `/api/admin/warehouse/receipts/${confirmReceiptId}/confirm`
|
|
||||||
: "/api/admin/warehouse/receipts/0/confirm",
|
|
||||||
method: () => "POST",
|
|
||||||
invalidate: ["warehouse"],
|
|
||||||
onSuccess: () => {
|
|
||||||
alert.success("Doklad byl potvrzen");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveReceipt = async (): Promise<number | null> => {
|
const saveReceipt = async (): Promise<number | null> => {
|
||||||
try {
|
try {
|
||||||
const data = await saveMutation.mutateAsync(buildPayload());
|
const data = await saveMutation.mutateAsync(buildPayload());
|
||||||
@@ -304,13 +348,10 @@ export default function WarehouseReceiptForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmReceipt = async (receiptId: number) => {
|
const confirmReceipt = async (receiptId: number) => {
|
||||||
setConfirmReceiptId(receiptId);
|
|
||||||
try {
|
try {
|
||||||
await confirmMutation.mutateAsync(undefined);
|
await confirmMutation.mutateAsync(receiptId);
|
||||||
} 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 {
|
|
||||||
setConfirmReceiptId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -342,53 +383,6 @@ export default function WarehouseReceiptForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = useCallback(
|
|
||||||
async (files: FileList | File[]) => {
|
|
||||||
if (!isEdit || !id) return;
|
|
||||||
setUploadingFiles(true);
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
const response = await apiFetch(
|
|
||||||
`/api/admin/warehouse/receipts/${id}/attachments`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
alert.error(
|
|
||||||
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
|
||||||
alert.success("Soubory byly nahrány");
|
|
||||||
} catch {
|
|
||||||
alert.error("Chyba připojení");
|
|
||||||
} finally {
|
|
||||||
setUploadingFiles(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[id, isEdit, alert, queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.dataTransfer.files.length > 0) {
|
|
||||||
handleFileUpload(e.dataTransfer.files);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleFileUpload],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isEdit && isPending) {
|
if (isEdit && isPending) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
warehouseStockStatusOptions,
|
warehouseStockStatusOptions,
|
||||||
warehouseBelowMinimumOptions,
|
warehouseBelowMinimumOptions,
|
||||||
warehouseCategoryListOptions,
|
warehouseCategoryListOptions,
|
||||||
|
warehouseMovementLogOptions,
|
||||||
type WarehouseItem,
|
type WarehouseItem,
|
||||||
|
type MovementLogRow,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import apiFetch from "../utils/api";
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
import { formatCurrency } from "../utils/formatters";
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -43,16 +46,6 @@ interface ProjectConsumptionRow {
|
|||||||
total_value: number;
|
total_value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovementLogRow {
|
|
||||||
date: string;
|
|
||||||
type: string;
|
|
||||||
document_number: string;
|
|
||||||
item_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
supplier_or_project: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABS: TabDef[] = [
|
const TABS: TabDef[] = [
|
||||||
{ value: "stock-status", label: "Stav zásob" },
|
{ value: "stock-status", label: "Stav zásob" },
|
||||||
{ value: "project-consumption", label: "Spotřeba projektů" },
|
{ value: "project-consumption", label: "Spotřeba projektů" },
|
||||||
@@ -260,34 +253,36 @@ function ProjectConsumptionTab({
|
|||||||
name: string;
|
name: string;
|
||||||
}[]) ?? [];
|
}[]) ?? [];
|
||||||
|
|
||||||
const [data, setData] = useState<ProjectConsumptionRow[] | null>(null);
|
// Filters applied on "Zobrazit"; the query key carries them so results land in
|
||||||
const [loading, setLoading] = useState(false);
|
// the ["warehouse"] cache and refresh on warehouse mutations.
|
||||||
const [fetched, setFetched] = useState(false);
|
const [applied, setApplied] = useState<{
|
||||||
|
projectId: number | "";
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const {
|
||||||
setLoading(true);
|
data,
|
||||||
setFetched(true);
|
isFetching: loading,
|
||||||
try {
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["warehouse", "project-consumption", applied],
|
||||||
|
enabled: applied !== null,
|
||||||
|
queryFn: () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (projectId) params.set("project_id", String(projectId));
|
if (applied?.projectId)
|
||||||
if (dateFrom) params.set("date_from", dateFrom);
|
params.set("project_id", String(applied.projectId));
|
||||||
if (dateTo) params.set("date_to", dateTo);
|
if (applied?.dateFrom) params.set("date_from", applied.dateFrom);
|
||||||
|
if (applied?.dateTo) params.set("date_to", applied.dateTo);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const response = await apiFetch(
|
return jsonQuery<ProjectConsumptionRow[]>(
|
||||||
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
|
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
},
|
||||||
if (result.success) {
|
});
|
||||||
setData(result.data ?? []);
|
|
||||||
} else {
|
const fetched = applied !== null;
|
||||||
setData([]);
|
const handleFetch = () => setApplied({ projectId, dateFrom, dateTo });
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setData([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
|
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
|
||||||
{
|
{
|
||||||
@@ -352,7 +347,15 @@ function ProjectConsumptionTab({
|
|||||||
|
|
||||||
{fetched && loading && <LoadingState />}
|
{fetched && loading && <LoadingState />}
|
||||||
|
|
||||||
{fetched && !loading && data && (
|
{fetched && !loading && error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Nepodařilo se načíst spotřebu projektů"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetched && !loading && !error && data && (
|
||||||
<Card>
|
<Card>
|
||||||
<DataTable<ProjectConsumptionRow & { _idx: number }>
|
<DataTable<ProjectConsumptionRow & { _idx: number }>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -383,34 +386,29 @@ function MovementLogTab({
|
|||||||
dateTo: string;
|
dateTo: string;
|
||||||
setDateTo: (v: string) => void;
|
setDateTo: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<MovementLogRow[] | null>(null);
|
// Filters applied on "Zobrazit"; uses the shared movement-log query option so
|
||||||
const [loading, setLoading] = useState(false);
|
// results land in the ["warehouse"] cache and refresh on warehouse mutations.
|
||||||
const [fetched, setFetched] = useState(false);
|
const [applied, setApplied] = useState<{
|
||||||
|
type: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const {
|
||||||
setLoading(true);
|
data,
|
||||||
setFetched(true);
|
isFetching: loading,
|
||||||
try {
|
error,
|
||||||
const params = new URLSearchParams();
|
} = useQuery({
|
||||||
if (type) params.set("type", type);
|
...warehouseMovementLogOptions({
|
||||||
if (dateFrom) params.set("date_from", dateFrom);
|
type: applied?.type || undefined,
|
||||||
if (dateTo) params.set("date_to", dateTo);
|
date_from: applied?.dateFrom || undefined,
|
||||||
const qs = params.toString();
|
date_to: applied?.dateTo || undefined,
|
||||||
const response = await apiFetch(
|
}),
|
||||||
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
|
enabled: applied !== null,
|
||||||
);
|
});
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
const fetched = applied !== null;
|
||||||
setData(result.data ?? []);
|
const handleFetch = () => setApplied({ type, dateFrom, dateTo });
|
||||||
} else {
|
|
||||||
setData([]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setData([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
receipt: "Příjem",
|
receipt: "Příjem",
|
||||||
@@ -423,7 +421,7 @@ function MovementLogTab({
|
|||||||
header: "Datum",
|
header: "Datum",
|
||||||
width: "12%",
|
width: "12%",
|
||||||
mono: true,
|
mono: true,
|
||||||
render: (row) => row.date,
|
render: (row) => formatDate(row.date),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "type",
|
key: "type",
|
||||||
@@ -501,7 +499,15 @@ function MovementLogTab({
|
|||||||
|
|
||||||
{fetched && loading && <LoadingState />}
|
{fetched && loading && <LoadingState />}
|
||||||
|
|
||||||
{fetched && !loading && data && (
|
{fetched && !loading && error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Nepodařilo se načíst pohyby"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetched && !loading && !error && data && (
|
||||||
<Card>
|
<Card>
|
||||||
<DataTable<MovementLogRow & { _idx: number }>
|
<DataTable<MovementLogRow & { _idx: number }>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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";
|
||||||
@@ -9,12 +9,12 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
|
||||||
import { formatDate, czechPlural } from "../utils/formatters";
|
import { formatDate, czechPlural } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
warehouseReservationListOptions,
|
warehouseReservationListOptions,
|
||||||
type WarehouseReservation,
|
type WarehouseReservation,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { projectListOptions } from "../lib/queries/projects";
|
import { projectListOptions } from "../lib/queries/projects";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -106,11 +106,17 @@ interface ReservationForm {
|
|||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreateReservationPayload {
|
||||||
|
item_id: number;
|
||||||
|
project_id: number;
|
||||||
|
quantity: number;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function WarehouseReservations() {
|
export default function WarehouseReservations() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
@@ -148,6 +154,29 @@ export default function WarehouseReservations() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createMutation = useApiMutation<CreateReservationPayload, void>({
|
||||||
|
url: () => "/api/admin/warehouse/reservations",
|
||||||
|
method: () => "POST",
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
||||||
|
alert.success("Rezervace byla vytvořena");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useApiMutation<number, void>({
|
||||||
|
url: (reservationId) =>
|
||||||
|
`/api/admin/warehouse/reservations/${reservationId}/cancel`,
|
||||||
|
method: () => "POST",
|
||||||
|
invalidate: ["warehouse"],
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelTarget(null);
|
||||||
|
alert.success("Rezervace byla zrušena");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// All hooks above this line — permission gate is the last thing before render.
|
||||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||||
|
|
||||||
const canOperate = hasPermission("warehouse.operate");
|
const canOperate = hasPermission("warehouse.operate");
|
||||||
@@ -161,33 +190,25 @@ export default function WarehouseReservations() {
|
|||||||
const hasActiveFilters = statusFilter || projectId;
|
const hasActiveFilters = statusFilter || projectId;
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!form.item_id || !form.project_id || form.quantity <= 0) {
|
if (
|
||||||
|
!form.item_id ||
|
||||||
|
!form.project_id ||
|
||||||
|
!Number.isFinite(form.quantity) ||
|
||||||
|
form.quantity <= 0
|
||||||
|
) {
|
||||||
alert.error("Vyplňte položku, projekt a množství");
|
alert.error("Vyplňte položku, projekt a množství");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch("/api/admin/warehouse/reservations", {
|
await createMutation.mutateAsync({
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
item_id: form.item_id,
|
item_id: form.item_id,
|
||||||
project_id: form.project_id,
|
project_id: form.project_id,
|
||||||
quantity: form.quantity,
|
quantity: form.quantity,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
} catch (e) {
|
||||||
if (result.success) {
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
|
||||||
setShowCreateModal(false);
|
|
||||||
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
|
||||||
alert.success("Rezervace byla vytvořena");
|
|
||||||
} else {
|
|
||||||
alert.error(result.error || "Nepodařilo se vytvořit rezervaci");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error("Chyba připojení");
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -197,20 +218,9 @@ export default function WarehouseReservations() {
|
|||||||
if (!cancelTarget) return;
|
if (!cancelTarget) return;
|
||||||
setCancelling(true);
|
setCancelling(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
await cancelMutation.mutateAsync(cancelTarget.id);
|
||||||
`/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`,
|
} catch (e) {
|
||||||
{ method: "POST" },
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
|
||||||
setCancelTarget(null);
|
|
||||||
alert.success("Rezervace byla zrušena");
|
|
||||||
} else {
|
|
||||||
alert.error(result.error || "Nepodařilo se zrušit rezervaci");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error("Chyba připojení");
|
|
||||||
} finally {
|
} finally {
|
||||||
setCancelling(false);
|
setCancelling(false);
|
||||||
}
|
}
|
||||||
@@ -451,12 +461,13 @@ export default function WarehouseReservations() {
|
|||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={form.quantity || ""}
|
value={form.quantity || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
quantity: Number(e.target.value),
|
quantity: Number.isFinite(n) ? n : 0,
|
||||||
}))
|
}));
|
||||||
}
|
}}
|
||||||
inputProps={{ min: 0, step: 0.001 }}
|
inputProps={{ min: 0, step: 0.001 }}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -129,10 +129,6 @@ export default function WarehouseSuppliers() {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
supplier: WarehouseSupplier | null;
|
supplier: WarehouseSupplier | null;
|
||||||
}>({ show: false, supplier: null });
|
}>({ show: false, supplier: null });
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const submitMutation = useApiMutation<
|
const submitMutation = useApiMutation<
|
||||||
SupplierForm,
|
SupplierForm,
|
||||||
@@ -158,14 +154,14 @@ export default function WarehouseSuppliers() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// id is captured from the mutation variable (built into the URL), so it stays
|
||||||
|
// correct even when called immediately after a state update. The non-strict
|
||||||
|
// UpdateSupplierSchema strips the `id` field from the body.
|
||||||
const toggleActiveMutation = useApiMutation<
|
const toggleActiveMutation = useApiMutation<
|
||||||
{ is_active: boolean },
|
{ id: number; is_active: boolean },
|
||||||
{ message?: string }
|
{ message?: string }
|
||||||
>({
|
>({
|
||||||
url: () => {
|
url: ({ id }) => `${API_BASE}/${id}`,
|
||||||
if (!toggleActiveSupplierId) return API_BASE;
|
|
||||||
return `${API_BASE}/${toggleActiveSupplierId}`;
|
|
||||||
},
|
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["warehouse"],
|
invalidate: ["warehouse"],
|
||||||
});
|
});
|
||||||
@@ -210,13 +206,10 @@ export default function WarehouseSuppliers() {
|
|||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
try {
|
||||||
await submitMutation.mutateAsync(form);
|
await submitMutation.mutateAsync(form);
|
||||||
} 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 {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,9 +224,9 @@ export default function WarehouseSuppliers() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleActive = async (supplier: WarehouseSupplier) => {
|
const toggleActive = async (supplier: WarehouseSupplier) => {
|
||||||
setToggleActiveSupplierId(supplier.id);
|
|
||||||
try {
|
try {
|
||||||
await toggleActiveMutation.mutateAsync({
|
await toggleActiveMutation.mutateAsync({
|
||||||
|
id: supplier.id,
|
||||||
is_active: !supplier.is_active,
|
is_active: !supplier.is_active,
|
||||||
});
|
});
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -243,8 +236,6 @@ export default function WarehouseSuppliers() {
|
|||||||
);
|
);
|
||||||
} 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 {
|
|
||||||
setToggleActiveSupplierId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -403,7 +394,7 @@ export default function WarehouseSuppliers() {
|
|||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||||
loading={saving}
|
loading={submitMutation.isPending}
|
||||||
>
|
>
|
||||||
<Field label="Název" required error={errors.name}>
|
<Field label="Název" required error={errors.name}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export const theme = createTheme({
|
|||||||
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
|
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
|
||||||
},
|
},
|
||||||
"&:active": { transform: "translateY(0) scale(0.97)" },
|
"&:active": { transform: "translateY(0) scale(0.97)" },
|
||||||
|
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
|
||||||
|
// the lift/press transform (glow + brightness stay, they don't move).
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
"&:hover": { transform: "none" },
|
||||||
|
"&:active": { transform: "none" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
|
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
|
||||||
// contrastText → near-black text on colored fills in dark mode, which
|
// contrastText → near-black text on colored fills in dark mode, which
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export default function Alert({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
<MuiAlert
|
||||||
|
severity={severity}
|
||||||
|
onClose={onClose}
|
||||||
|
// Czech label for the close button (MUI defaults to English "Close").
|
||||||
|
slotProps={{ closeButton: { "aria-label": "Zavřít" } }}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MuiAlert>
|
</MuiAlert>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
@@ -19,14 +19,24 @@ export default function AppShell() {
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const logoutTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
setLogoutAlert();
|
setLogoutAlert();
|
||||||
setTimeout(() => logout(), 400);
|
// Delay so the blur/scale exit animation can play; store the id so an
|
||||||
|
// unmount within the 400ms window cancels it (avoids logout() firing on
|
||||||
|
// an unmounted tree).
|
||||||
|
logoutTimer.current = setTimeout(() => logout(), 400);
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoutTimer.current) clearTimeout(logoutTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScopedCssBaseline>
|
<ScopedCssBaseline>
|
||||||
@@ -138,6 +148,7 @@ export default function AppShell() {
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import MuiCard, { type CardProps } from "@mui/material/Card";
|
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||||
import CardContent from "@mui/material/CardContent";
|
import CardContent, { type CardContentProps } from "@mui/material/CardContent";
|
||||||
|
|
||||||
export default function Card({ children, ...props }: CardProps) {
|
export interface AppCardProps extends CardProps {
|
||||||
|
/**
|
||||||
|
* Skip the default CardContent wrapper so children sit edge-to-edge (e.g. for
|
||||||
|
* media, tables, or a custom layout). Defaults to false — the standard padded
|
||||||
|
* content behavior is unchanged for existing call sites.
|
||||||
|
*/
|
||||||
|
disableContentPadding?: boolean;
|
||||||
|
/** Props forwarded to the inner CardContent (ignored when disableContentPadding). */
|
||||||
|
contentProps?: CardContentProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({
|
||||||
|
children,
|
||||||
|
disableContentPadding = false,
|
||||||
|
contentProps,
|
||||||
|
...props
|
||||||
|
}: AppCardProps) {
|
||||||
return (
|
return (
|
||||||
<MuiCard {...props}>
|
<MuiCard {...props}>
|
||||||
<CardContent>{children}</CardContent>
|
{disableContentPadding ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<CardContent {...contentProps}>{children}</CardContent>
|
||||||
|
)}
|
||||||
</MuiCard>
|
</MuiCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ export function CheckboxField({
|
|||||||
label,
|
label,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
indeterminate,
|
||||||
|
required,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
indeterminate?: boolean;
|
||||||
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -18,9 +28,16 @@ export function CheckboxField({
|
|||||||
<MuiCheckbox
|
<MuiCheckbox
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
required={required}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={label}
|
label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,15 +41,31 @@ export default function ConfirmDialog({
|
|||||||
// this the button would flash back to "Smazat"/confirmText). React
|
// this the button would flash back to "Smazat"/confirmText). React
|
||||||
// "adjust state from props during render": update only while open. Also keeps
|
// "adjust state from props during render": update only while open. Also keeps
|
||||||
// content from flashing empty when the caller clears its row on close.
|
// content from flashing empty when the caller clears its row on close.
|
||||||
const [shown, setShown] = useState({ title, message, confirmText, loading });
|
const [shown, setShown] = useState({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
confirmVariant,
|
||||||
|
loading,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
isOpen &&
|
isOpen &&
|
||||||
(shown.title !== title ||
|
(shown.title !== title ||
|
||||||
shown.message !== message ||
|
shown.message !== message ||
|
||||||
shown.confirmText !== confirmText ||
|
shown.confirmText !== confirmText ||
|
||||||
|
shown.cancelText !== cancelText ||
|
||||||
|
shown.confirmVariant !== confirmVariant ||
|
||||||
shown.loading !== loading)
|
shown.loading !== loading)
|
||||||
) {
|
) {
|
||||||
setShown({ title, message, confirmText, loading });
|
setShown({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
confirmVariant,
|
||||||
|
loading,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,12 +82,12 @@ export default function ConfirmDialog({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
||||||
{cancelText}
|
{shown.cancelText}
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
<MuiButton
|
<MuiButton
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={confirmVariant === "danger" ? "error" : "primary"}
|
color={shown.confirmVariant === "danger" ? "error" : "primary"}
|
||||||
disabled={shown.loading}
|
disabled={shown.loading}
|
||||||
>
|
>
|
||||||
{shown.loading ? "Zpracovávám…" : shown.confirmText}
|
{shown.loading ? "Zpracovávám…" : shown.confirmText}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import TableContainer from "@mui/material/TableContainer";
|
|||||||
import TableHead from "@mui/material/TableHead";
|
import TableHead from "@mui/material/TableHead";
|
||||||
import TableRow from "@mui/material/TableRow";
|
import TableRow from "@mui/material/TableRow";
|
||||||
import TableSortLabel from "@mui/material/TableSortLabel";
|
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import MuiTextField from "@mui/material/TextField";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
|
||||||
export interface DataColumn<T> {
|
export interface DataColumn<T> {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -78,8 +81,56 @@ export default function DataTable<T>({
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
const actionCol = columns.find((c) => c.key === "actions");
|
const actionCol = columns.find((c) => c.key === "actions");
|
||||||
const dataCols = columns.filter((c) => c.key !== "actions");
|
const dataCols = columns.filter((c) => c.key !== "actions");
|
||||||
|
// The header row (with its sort labels) is gone in the card layout, so
|
||||||
|
// surface the same columns[].sortKey/onSort wiring through a compact
|
||||||
|
// dropdown + direction toggle, otherwise mobile users can't sort at all.
|
||||||
|
const sortCols = columns.filter((c) => c.sortKey);
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
{onSort && sortCols.length > 0 && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<MuiTextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Řadit podle"
|
||||||
|
value={sortBy ?? ""}
|
||||||
|
onChange={(e) => onSort(e.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{sortCols.map((c) => (
|
||||||
|
<MenuItem key={c.key} value={c.sortKey!}>
|
||||||
|
{c.header}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MuiTextField>
|
||||||
|
<IconButton
|
||||||
|
aria-label={
|
||||||
|
sortDir === "desc" ? "Řadit vzestupně" : "Řadit sestupně"
|
||||||
|
}
|
||||||
|
disabled={!sortBy}
|
||||||
|
onClick={() => sortBy && onSort(sortBy)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
transform:
|
||||||
|
sortDir === "desc" ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const extra = rowSx?.(row);
|
const extra = rowSx?.(row);
|
||||||
return (
|
return (
|
||||||
@@ -255,6 +306,14 @@ export default function DataTable<T>({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={c.key}
|
key={c.key}
|
||||||
align={c.align}
|
align={c.align}
|
||||||
|
// The actions cell must not bubble its click to the row: an
|
||||||
|
// action button inside an onRowClick row would otherwise
|
||||||
|
// fire both the action AND the row navigation.
|
||||||
|
onClick={
|
||||||
|
onRowClick && c.key === "actions"
|
||||||
|
? (e) => e.stopPropagation()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: ".8rem",
|
fontSize: ".8rem",
|
||||||
...(c.bold ? { fontWeight: 500 } : {}),
|
...(c.bold ? { fontWeight: 500 } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { ReactNode } from "react";
|
import {
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
useId,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
@@ -18,10 +24,44 @@ export function Field({
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
// Associate the <label> with the control. Generate a stable id, render it via
|
||||||
|
// `htmlFor`, and inject the same id onto the single input child so click-to-
|
||||||
|
// focus works and screen readers announce the label. Wire error/hint via
|
||||||
|
// aria-describedby + aria-invalid for assistive tech. We only inject onto a
|
||||||
|
// valid element that hasn't already set `id`, so explicit caller ids win.
|
||||||
|
const reactId = useId();
|
||||||
|
const generatedId = `field-${reactId}`;
|
||||||
|
// If the child already carries an id, keep it as the htmlFor target so the
|
||||||
|
// label still points at the real input; otherwise inject the generated one.
|
||||||
|
const childId = isValidElement(children)
|
||||||
|
? (children.props as { id?: string }).id
|
||||||
|
: undefined;
|
||||||
|
const inputId = childId ?? generatedId;
|
||||||
|
const describedById = error
|
||||||
|
? `${inputId}-error`
|
||||||
|
: hint
|
||||||
|
? `${inputId}-hint`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let control = children;
|
||||||
|
if (isValidElement(children)) {
|
||||||
|
const extra: Record<string, unknown> = {};
|
||||||
|
if (childId == null) extra.id = inputId;
|
||||||
|
if (describedById) extra["aria-describedby"] = describedById;
|
||||||
|
if (error) extra["aria-invalid"] = true;
|
||||||
|
if (Object.keys(extra).length > 0) {
|
||||||
|
control = cloneElement(
|
||||||
|
children as ReactElement<Record<string, unknown>>,
|
||||||
|
extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography
|
<Typography
|
||||||
component="label"
|
component="label"
|
||||||
|
htmlFor={inputId}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -37,13 +77,13 @@ export function Field({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{children}
|
{control}
|
||||||
{error ? (
|
{error ? (
|
||||||
<Typography variant="caption" color="error">
|
<Typography id={describedById} variant="caption" color="error">
|
||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : hint ? (
|
) : hint ? (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography id={describedById} variant="caption" color="text.secondary">
|
||||||
{hint}
|
{hint}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -56,20 +96,24 @@ export function SwitchField({
|
|||||||
label,
|
label,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={label}
|
label={label}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function LoadingState({ label }: LoadingStateProps) {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress size={36} />
|
<CircularProgress size={36} aria-label={label ?? "Načítání"} />
|
||||||
{label && (
|
{label && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -46,15 +46,22 @@ export default function Modal({
|
|||||||
// title/submitText NOR the loading state flips during the close fade-out
|
// title/submitText NOR the loading state flips during the close fade-out
|
||||||
// (e.g. on save, `loading` goes false a beat before the dialog finishes
|
// (e.g. on save, `loading` goes false a beat before the dialog finishes
|
||||||
// closing — without this the button would flash back to "Uložit změny").
|
// closing — without this the button would flash back to "Uložit změny").
|
||||||
const [shown, setShown] = useState({ title, subtitle, submitText, loading });
|
const [shown, setShown] = useState({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
submitText,
|
||||||
|
cancelText,
|
||||||
|
loading,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
isOpen &&
|
isOpen &&
|
||||||
(shown.title !== title ||
|
(shown.title !== title ||
|
||||||
shown.subtitle !== subtitle ||
|
shown.subtitle !== subtitle ||
|
||||||
shown.submitText !== submitText ||
|
shown.submitText !== submitText ||
|
||||||
|
shown.cancelText !== cancelText ||
|
||||||
shown.loading !== loading)
|
shown.loading !== loading)
|
||||||
) {
|
) {
|
||||||
setShown({ title, subtitle, submitText, loading });
|
setShown({ title, subtitle, submitText, cancelText, loading });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +85,7 @@ export default function Modal({
|
|||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
{!hideCancel && (
|
{!hideCancel && (
|
||||||
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
||||||
{cancelText}
|
{shown.cancelText}
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
)}
|
)}
|
||||||
<MuiButton
|
<MuiButton
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
|||||||
import { cs } from "date-fns/locale";
|
import { cs } from "date-fns/locale";
|
||||||
import { theme } from "../theme";
|
import { theme } from "../theme";
|
||||||
import AppGlobalStyles from "../GlobalStyles";
|
import AppGlobalStyles from "../GlobalStyles";
|
||||||
|
import { DEFAULT_THEME_MODE } from "../../context/ThemeContext";
|
||||||
|
|
||||||
export default function MuiProvider({ children }: { children: ReactNode }) {
|
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||||
|
// `defaultMode` is only the first-paint fallback before a stored preference is
|
||||||
|
// read. It is pinned to ThemeContext's single `DEFAULT_THEME_MODE` constant
|
||||||
|
// (the real source of truth for `<html data-theme>` / the `mui-mode` storage
|
||||||
|
// key) so the two defaults can never drift and cause a one-frame mismatch.
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme} defaultMode="dark">
|
<ThemeProvider theme={theme} defaultMode={DEFAULT_THEME_MODE}>
|
||||||
<AppGlobalStyles />
|
<AppGlobalStyles />
|
||||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user