From 519edce373fc7996430b54b0ddb372793032ebdc Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 06:45:26 +0200 Subject: [PATCH] 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) --- .env.example | 62 +- .gitignore | 7 + .prettierignore | 7 + .prettierrc.json | 7 + eslint.config.mjs | 78 + package-lock.json | 1543 +++++++++++++++-- package.json | 18 +- prisma/seed.ts | 16 + scripts/migrate-received-invoices-to-nas.ts | 34 + scripts/rotate-totp-key.ts | 58 +- src/__tests__/ai.test.ts | 40 +- src/__tests__/auth.service.test.ts | 72 +- src/__tests__/auth.test.ts | 172 +- src/__tests__/customers.schema.test.ts | 88 +- src/__tests__/env.test.ts | 34 + src/__tests__/exchange-rates.test.ts | 38 +- src/__tests__/helpers.ts | 22 +- src/__tests__/invoices.service.test.ts | 131 +- src/__tests__/manual-create.test.ts | 71 +- src/__tests__/nas-file-manager.test.ts | 75 +- src/__tests__/numbering.test.ts | 76 +- src/__tests__/plan.test.ts | 86 +- src/__tests__/planCategory.test.ts | 27 +- src/__tests__/received-invoices-vat.test.ts | 25 + src/__tests__/schema-nan.test.ts | 252 ++- src/__tests__/setup.ts | 44 +- src/__tests__/warehouse.test.ts | 129 +- src/admin/AdminApp.tsx | 9 + src/admin/components/AlertContainer.tsx | 5 +- src/admin/components/AttendanceShiftTable.tsx | 5 +- src/admin/components/BulkAttendanceModal.tsx | 2 +- src/admin/components/ErrorBoundary.tsx | 3 + .../components/OrderConfirmationModal.tsx | 26 +- src/admin/components/PlanCategoriesModal.tsx | 4 + src/admin/components/PlanCellModal.tsx | 173 +- src/admin/components/PlanGrid.tsx | 21 +- src/admin/components/PlanRangeChips.tsx | 7 +- src/admin/components/ProjectFileManager.tsx | 42 +- src/admin/components/RichEditor.tsx | 4 +- src/admin/components/ShiftFormModal.tsx | 19 +- src/admin/components/ShortcutsHelp.tsx | 4 + .../components/dashboard/DashKpiCards.tsx | 7 +- .../components/dashboard/DashProfile.tsx | 42 +- .../components/dashboard/DashQuickActions.tsx | 59 +- .../components/dashboard/DashSessions.tsx | 7 +- src/admin/components/odin/OdinSidebar.tsx | 13 + src/admin/components/warehouse/ItemPicker.tsx | 5 +- .../warehouse/ReservationPicker.tsx | 23 +- src/admin/context/AuthContext.tsx | 10 +- src/admin/hooks/useModalLock.ts | 9 + src/admin/hooks/usePaginatedQuery.ts | 13 +- src/admin/hooks/usePlanWork.ts | 446 +++-- src/admin/hooks/useReducedMotion.ts | 14 +- src/admin/lib/queries/attendance.ts | 14 +- src/admin/lib/queries/dashboard.ts | 14 +- src/admin/pages/Attendance.tsx | 27 +- src/admin/pages/AttendanceBalances.tsx | 10 +- src/admin/pages/AttendanceCreate.tsx | 2 +- src/admin/pages/AttendanceLocation.tsx | 11 +- src/admin/pages/AuditLog.tsx | 8 +- src/admin/pages/CompanySettings.tsx | 2 +- src/admin/pages/InvoiceDetail.tsx | 50 +- src/admin/pages/Invoices.tsx | 14 +- src/admin/pages/LeaveApproval.tsx | 4 +- src/admin/pages/Login.tsx | 13 +- src/admin/pages/OfferDetail.tsx | 2 +- src/admin/pages/OffersCustomers.tsx | 6 +- src/admin/pages/OrderDetail.tsx | 8 +- src/admin/pages/ProjectDetail.tsx | 4 +- src/admin/pages/Projects.tsx | 4 +- src/admin/pages/ReceivedInvoices.tsx | 21 +- src/admin/pages/Settings.tsx | 10 +- src/admin/pages/Trips.tsx | 10 +- src/admin/pages/TripsAdmin.tsx | 4 +- src/admin/pages/Vehicles.tsx | 11 +- src/admin/pages/Warehouse.tsx | 12 +- src/admin/pages/WarehouseCategories.tsx | 15 +- src/admin/pages/WarehouseInventory.tsx | 7 +- src/admin/pages/WarehouseInventoryDetail.tsx | 15 +- src/admin/pages/WarehouseInventoryForm.tsx | 37 +- src/admin/pages/WarehouseIssueDetail.tsx | 31 +- src/admin/pages/WarehouseIssueForm.tsx | 52 +- src/admin/pages/WarehouseItemDetail.tsx | 15 +- src/admin/pages/WarehouseLocations.tsx | 9 +- src/admin/pages/WarehouseReceiptDetail.tsx | 40 +- src/admin/pages/WarehouseReceiptForm.tsx | 146 +- src/admin/pages/WarehouseReports.tsx | 136 +- src/admin/pages/WarehouseReservations.tsx | 95 +- src/admin/pages/WarehouseSuppliers.tsx | 23 +- src/admin/theme.ts | 6 + src/admin/ui/Alert.tsx | 8 +- src/admin/ui/AppShell.tsx | 15 +- src/admin/ui/Card.tsx | 26 +- src/admin/ui/Checkbox.tsx | 17 + src/admin/ui/ConfirmDialog.tsx | 24 +- src/admin/ui/DataTable.tsx | 59 + src/admin/ui/Field.tsx | 52 +- src/admin/ui/LoadingState.tsx | 2 +- src/admin/ui/Modal.tsx | 13 +- src/admin/ui/MuiProvider.tsx | 7 +- src/admin/ui/ProgressBar.tsx | 4 + src/admin/ui/Tabs.tsx | 24 +- src/admin/ui/navData.tsx | 8 +- src/admin/ui/useDialogScrollLock.ts | 5 +- src/admin/utils/attendanceHelpers.ts | 8 +- src/admin/utils/dashboardHelpers.ts | 11 +- src/context/ThemeContext.tsx | 53 +- src/routes/admin/ai.ts | 10 +- src/routes/admin/attendance.ts | 63 +- src/routes/admin/audit-log.ts | 25 +- src/routes/admin/company-settings.ts | 19 +- src/routes/admin/customers.ts | 12 +- src/routes/admin/dashboard.ts | 32 +- src/routes/admin/invoices-pdf.ts | 17 +- src/routes/admin/invoices.ts | 12 +- src/routes/admin/leave-requests.ts | 18 +- src/routes/admin/offers-pdf.ts | 16 +- src/routes/admin/orders-pdf.ts | 53 +- src/routes/admin/orders.ts | 16 +- src/routes/admin/plan.ts | 16 +- src/routes/admin/project-files.ts | 43 +- src/routes/admin/projects.ts | 46 +- src/routes/admin/quotations.ts | 25 +- src/routes/admin/received-invoices.ts | 30 +- src/routes/admin/roles.ts | 72 +- src/routes/admin/scope-templates.ts | 94 +- src/routes/admin/totp.ts | 20 +- src/routes/admin/trips.ts | 232 +-- src/routes/admin/vehicles.ts | 71 +- src/routes/admin/warehouse.ts | 420 +++-- src/schemas/ai.schema.ts | 6 +- src/schemas/attendance.schema.ts | 128 +- src/schemas/auth.schema.ts | 21 +- src/schemas/bank-accounts.schema.ts | 45 +- src/schemas/common.ts | 109 +- src/schemas/customers.schema.ts | 32 +- src/schemas/invoices.schema.ts | 146 +- src/schemas/leave-requests.schema.ts | 9 +- src/schemas/offers.schema.ts | 115 +- src/schemas/orders.schema.ts | 136 +- src/schemas/plan.schema.ts | 35 +- src/schemas/profile.schema.ts | 12 +- src/schemas/projects.schema.ts | 45 +- src/schemas/received-invoices.schema.ts | 78 +- src/schemas/roles.schema.ts | 14 +- src/schemas/scope-templates.schema.ts | 45 +- src/schemas/settings.schema.ts | 57 +- src/schemas/trips.schema.ts | 52 +- src/schemas/users.schema.ts | 23 +- src/schemas/vehicles.schema.ts | 31 +- src/schemas/warehouse.schema.ts | 227 +-- src/services/ai.service.ts | 10 +- src/services/attendance.service.ts | 197 ++- src/services/audit.ts | 14 +- src/services/auth.ts | 22 +- src/services/exchange-rates.ts | 45 +- src/services/invoice-alerts.ts | 213 +-- src/services/invoices.service.ts | 200 ++- src/services/leave-notification.ts | 14 +- src/services/mailer.ts | 42 +- src/services/nas-file-manager.ts | 12 +- src/services/nas-financials-manager.ts | 20 + src/services/nas-offers-manager.ts | 35 +- src/services/numbering.service.ts | 83 +- src/services/offers.service.ts | 161 +- src/services/orders.service.ts | 183 +- src/services/plan.service.ts | 136 +- src/services/planCategory.service.ts | 33 +- src/services/projects.service.ts | 28 +- src/services/system-settings.ts | 21 +- src/services/users.service.ts | 82 +- src/services/warehouse.service.ts | 201 ++- src/types/index.ts | 3 + src/utils/czech-holidays.ts | 10 +- src/utils/encryption.ts | 32 +- src/utils/html-to-pdf.ts | 33 +- src/utils/pagination.ts | 30 +- tsconfig.json | 3 +- tsconfig.test.json | 24 + 179 files changed, 7179 insertions(+), 2844 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.mjs create mode 100644 tsconfig.test.json diff --git a/.env.example b/.env.example index 49dd1ec..e1110d3 100644 --- a/.env.example +++ b/.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 - -# Server -PORT=3001 -HOST=127.0.0.1 -APP_ENV=local - # Auth — MUST regenerate for production: openssl rand -hex 32 JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32 +# TOTP — MUST regenerate for production: openssl rand -hex 32 +TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32 + +# ── Server ──────────────────────────────────────────────────────────────── +PORT=3001 # production port (dev is hardcoded to 3050) +HOST=127.0.0.1 +APP_ENV=local # local | production — controls CSP/CORS/HSTS +APP_URL= # base URL used in email links +# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs + +# ── Tokens (seconds) ────────────────────────────────────────────────────── ACCESS_TOKEN_EXPIRY=900 REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 -# TOTP — MUST regenerate for production: openssl rand -hex 32 -TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32 +# ── TOTP / 2FA (optional, sensible defaults) ────────────────────────────── +# TOTP_ALGORITHM=SHA1 +# TOTP_DIGITS=6 +# TOTP_PERIOD=30 +# LOGIN_TOKEN_EXPIRY_MINUTES=5 -# File storage +# ── Rate limiting (optional) ────────────────────────────────────────────── +# RATE_LIMIT_MAX=300 +# RATE_LIMIT_WINDOW=1 minute +# RATE_LIMIT_LOGIN=20 +# RATE_LIMIT_TOTP=5 +# RATE_LIMIT_REFRESH=10 + +# ── File storage (NAS network shares) ───────────────────────────────────── NAS_PATH=Z:/02_PROJEKTY -MAX_UPLOAD_SIZE=52428800 +# NAS_FINANCIALS_PATH= +# NAS_OFFERS_PATH= +MAX_UPLOAD_SIZE=52428800 # 50 MB -# Email +# ── Email ───────────────────────────────────────────────────────────────── CONTACT_EMAIL_TO= CONTACT_EMAIL_FROM= SMTP_FROM= +# SMTP_FROM_NAME= LEAVE_NOTIFY_EMAIL= +# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron +# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset): +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER= +# SMTP_PASS= -# App URL (for email links) -APP_URL= - -# CORS (production only, comma-separated) +# ── CORS (production only, comma-separated) ─────────────────────────────── CORS_ORIGINS= + +# ── AI assistant "Odin" (optional; feature self-hides when unset) ───────── +# ANTHROPIC_API_KEY= diff --git a/.gitignore b/.gitignore index b95b9a2..b340a26 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,13 @@ dist/ dist-client/ *.css.map +# Build / tooling artifacts +*.tsbuildinfo +*.bak + +# Local scratch +.playwright-mcp/ + # Release archives *.tar.gz diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5d2aee0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist/ +dist-client/ +node_modules/ +prisma/migrations/ +src/admin/bones/ +*.tsbuildinfo +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..638625c --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..d1b6cd9 --- /dev/null +++ b/eslint.config.mjs @@ -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, +); diff --git a/package-lock.json b/package-lock.json index 55b7ade..d69d2c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "2.0.8", + "version": "2.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "2.0.8", + "version": "2.1.5", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.102.0", @@ -33,7 +33,6 @@ "fastify": "^5.8.2", "file-type": "^16.5.4", "framer-motion": "^12.38.0", - "hi-base32": "^0.5.1", "jsdom": "^29.0.2", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", @@ -44,18 +43,17 @@ "puppeteer": "^24.40.0", "qrcode": "^1.5.4", "react": "^18.3.1", - "react-datepicker": "^9.1.0", "react-dom": "^18.3.1", "react-quill-new": "^3.8.3", "react-router-dom": "^6.30.3", "zod": "^4.3.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/bcryptjs": "^2.4.6", "@types/dompurify": "^3.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", - "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", @@ -65,9 +63,16 @@ "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", "concurrently": "^9.2.1", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "prettier": "^3.8.3", "supertest": "^7.2.2", "tsx": "^4.21.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0", "vite": "^8.0.0", "vitest": "^4.1.0" } @@ -154,6 +159,57 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -170,6 +226,43 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -192,6 +285,24 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -210,6 +321,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -1129,6 +1264,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -1426,58 +1689,71 @@ "glob": "^13.0.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react": { - "version": "0.27.19", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", - "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", - "tabbable": "^6.0.0" + "@humanfs/types": "^0.15.0" }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -1489,6 +1765,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1999,9 +2286,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -2374,6 +2661,13 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2406,6 +2700,13 @@ "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -2441,16 +2742,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -2575,6 +2866,236 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2732,6 +3253,29 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3029,6 +3573,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-ftp": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", @@ -3057,9 +3614,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3068,6 +3625,40 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -3196,6 +3787,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3462,6 +4074,21 @@ } } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -3545,6 +4172,13 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -3706,6 +4340,13 @@ "fast-check": "^3.23.1" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3905,6 +4546,247 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -3918,6 +4800,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4061,6 +4969,13 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stringify": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", @@ -4085,6 +5000,13 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -4208,6 +5130,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -4258,6 +5193,27 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4344,6 +5300,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4468,6 +5434,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4532,12 +5524,23 @@ "node": ">= 0.4" } }, - "node_modules/hi-base32": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", - "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, "license": "MIT" }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4631,6 +5634,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4647,6 +5660,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4692,6 +5715,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4701,12 +5734,32 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4810,6 +5863,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4854,6 +5914,26 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -4897,12 +5977,36 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -5442,6 +6546,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -5466,6 +6577,16 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nodemailer": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", @@ -5555,6 +6676,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/otpauth": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", @@ -5692,6 +6831,16 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -5859,6 +7008,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prisma": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", @@ -6127,9 +7302,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6199,27 +7374,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-datepicker": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz", - "integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.15", - "clsx": "^2.1.1", - "date-fns": "^4.1.0" - }, - "peerDependencies": { - "date-fns-tz": "^3.0.0", - "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "date-fns-tz": { - "optional": true - } - } - }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6255,12 +7409,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -6270,13 +7424,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -6620,6 +7774,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -6976,12 +8153,6 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "license": "MIT" - }, "node_modules/tar-fs": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", @@ -7202,6 +8373,19 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7228,6 +8412,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typed-query-selector": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", @@ -7248,6 +8445,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -7263,6 +8484,47 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -7482,6 +8744,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -7505,6 +8783,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7529,9 +8817,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7573,6 +8861,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -7610,6 +8905,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -7618,6 +8926,19 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 83cfb5e..80dc4e3 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "db:studio": "prisma studio", "test": "vitest run", "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": { "seed": "tsx prisma/seed.ts" @@ -52,7 +57,6 @@ "fastify": "^5.8.2", "file-type": "^16.5.4", "framer-motion": "^12.38.0", - "hi-base32": "^0.5.1", "jsdom": "^29.0.2", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", @@ -63,18 +67,17 @@ "puppeteer": "^24.40.0", "qrcode": "^1.5.4", "react": "^18.3.1", - "react-datepicker": "^9.1.0", "react-dom": "^18.3.1", "react-quill-new": "^3.8.3", "react-router-dom": "^6.30.3", "zod": "^4.3.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/bcryptjs": "^2.4.6", "@types/dompurify": "^3.0.5", "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", - "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", @@ -84,9 +87,16 @@ "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", "concurrently": "^9.2.1", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "prettier": "^3.8.3", "supertest": "^7.2.2", "tsx": "^4.21.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0", "vite": "^8.0.0", "vitest": "^4.1.0" } diff --git a/prisma/seed.ts b/prisma/seed.ts index 4ba4174..b35c6f7 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -315,6 +315,22 @@ const PERMISSIONS: { ]; async function main() { + // HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and + // role_permissions (and seeds an admin/admin user) — purely dev-convenience + // behavior. Production baseline data must come from tracked Prisma migrations, + // never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on + // production so the destructive wipe can never hit a live database. + if (process.env.APP_ENV === "production") { + throw new Error( + "Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " + + "Seed maže všechna oprávnění a je určen pouze pro vývoj. " + + "Produkční data musí být zavedena migrací.\n" + + "Refusing to run: prisma/seed.ts must NEVER run on production " + + "(APP_ENV=production). It wipes all permissions and is dev-only; " + + "seed production baseline data via a migration instead.", + ); + } + console.log("Seeding permissions..."); // 1. Clear existing permissions and re-insert current set diff --git a/scripts/migrate-received-invoices-to-nas.ts b/scripts/migrate-received-invoices-to-nas.ts index 0d02c98..8d720d7 100644 --- a/scripts/migrate-received-invoices-to-nas.ts +++ b/scripts/migrate-received-invoices-to-nas.ts @@ -5,6 +5,7 @@ */ import "../src/config/env"; +import fs from "fs"; import { PrismaClient } from "@prisma/client"; import { nasFinancialsManager } from "../src/services/nas-financials-manager"; @@ -51,6 +52,39 @@ async function main() { continue; } + // Read-back verification before we null out the ONLY DB copy of the bytes. + // The manager uses writeFileSync (synchronous) and returns a relative path; + // resolve it back to disk and confirm the file exists and its size matches + // the source buffer. A 0-byte/truncated/corrupt write would otherwise lose + // the invoice permanently once file_data is set to null. If verification + // fails we KEEP file_data and report the row as failed. + const expectedSize = rec.file_data.length; + const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath); + let writtenSize = -1; + if (readBack) { + writtenSize = readBack.data.length; + } else { + // Fall back to a direct stat in case readReceivedInvoice's path guard + // rejects an otherwise-valid path; either way we need a confirmed size. + try { + writtenSize = fs.statSync( + (nasFinancialsManager as unknown as { basePath: string }).basePath + + "/" + + result.filePath, + ).size; + } catch { + writtenSize = -1; + } + } + + if (writtenSize !== expectedSize) { + console.error( + ` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`, + ); + failed++; + continue; + } + await prisma.received_invoices.update({ where: { id: rec.id }, data: { file_path: result.filePath, file_data: null }, diff --git a/scripts/rotate-totp-key.ts b/scripts/rotate-totp-key.ts index fb2053d..003f73a 100644 --- a/scripts/rotate-totp-key.ts +++ b/scripts/rotate-totp-key.ts @@ -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(); -function decrypt(ciphertext: string, keyHex: string): string { - const key = Buffer.from(keyHex, 'hex'); - const [ivHex, encHex, tagHex] = ciphertext.split(':'); - const iv = Buffer.from(ivHex, 'hex'); - const encrypted = Buffer.from(encHex, 'hex'); - const tag = Buffer.from(tagHex, 'hex'); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8'); -} - -function encrypt(plaintext: string, keyHex: string): string { - const key = Buffer.from(keyHex, 'hex'); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`; -} +// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this +// script handles BOTH wire formats: the TS `hex:hex:hex` form and the +// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed +// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret — +// inside the $transaction that aborted/rolled back the ENTIRE batch on the first +// legacy user. These key-parameterized helpers accept the old/new keys passed on +// the command line (the config-bound `decrypt`/`encrypt` use the env key only). +const decrypt = (ciphertext: string, keyHex: string): string => + decryptWithKey(ciphertext, keyHex); +const encrypt = (plaintext: string, keyHex: string): string => + encryptWithKey(plaintext, keyHex); async function main() { const oldKey = process.argv[2]; const newKey = process.argv[3]; - const dryRun = process.argv.includes('--dry-run'); + const dryRun = process.argv.includes("--dry-run"); if (!oldKey || !newKey) { - console.error('Usage: tsx scripts/rotate-totp-key.ts [--dry-run]'); + console.error( + "Usage: tsx scripts/rotate-totp-key.ts [--dry-run]", + ); process.exit(1); } @@ -46,12 +40,14 @@ async function main() { const decrypted = decrypt(user.totp_secret!, oldKey); const reEncrypted = encrypt(decrypted, newKey); decrypt(reEncrypted, newKey); // verify round-trip - console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`); + console.log( + ` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`, + ); } catch (e) { console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`); } } - console.log('\nDry run complete. No changes made.'); + console.log("\nDry run complete. No changes made."); return; } @@ -59,13 +55,19 @@ async function main() { for (const user of users) { const decrypted = decrypt(user.totp_secret!, oldKey); const reEncrypted = encrypt(decrypted, newKey); - await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } }); + await tx.users.update({ + where: { id: user.id }, + data: { totp_secret: reEncrypted }, + }); console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`); } }); - console.log('\nAll TOTP secrets re-encrypted successfully.'); + console.log("\nAll TOTP secrets re-encrypted successfully."); await prisma.$disconnect(); } -main().catch((e) => { console.error(e); process.exit(1); }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts index 6d1ce2d..ece6163 100644 --- a/src/__tests__/ai.test.ts +++ b/src/__tests__/ai.test.ts @@ -65,15 +65,21 @@ describe("ai.service cost + budget", () => { 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({ userId: null, kind: KIND, model: "claude-sonnet-4-6", inputTokens: 1_000_000, outputTokens: 0, - }); // $3 this month - // An old row (last year) must NOT count. + }); // +$3 this month + // An old row (year 2000) must NOT count toward the current month. await prisma.ai_usage.create({ data: { kind: KIND, @@ -84,9 +90,11 @@ describe("ai.service cost + budget", () => { created_at: new Date("2000-01-01T00:00:00Z"), }, }); - const spend = await getMonthSpendUsd(); - expect(spend).toBeGreaterThanOrEqual(3); - expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded + + const after = await getMonthSpendUsd(); + // Only the $3 current-month row moved the needle; the year-2000 $3 is + // excluded. The delta is exactly $3 (not $6). + expect(after - before).toBeCloseTo(3, 6); }); it("assertBudgetAvailable blocks at/over budget, allows under", async () => { @@ -94,7 +102,11 @@ describe("ai.service cost + budget", () => { // Under budget → null (allowed) expect(await assertBudgetAvailable()).toBeNull(); // 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: { kind: KIND, model: "claude-sonnet-4-6", @@ -104,9 +116,17 @@ describe("ai.service cost + budget", () => { created_at: new Date(), }, }); - const blocked = await assertBudgetAvailable(); - expect(blocked).not.toBeNull(); - expect(blocked?.status).toBe(402); + try { + const blocked = await assertBudgetAvailable(); + expect(blocked).not.toBeNull(); + expect(blocked?.status).toBe(402); + } finally { + // Remove the over-budget row immediately so it cannot inflate + // getMonthSpendUsd for any subsequently-running test. + await prisma.ai_usage + .delete({ where: { id: overBudget.id } }) + .catch(() => {}); + } }); it("isConfigured reflects the API key presence", () => { diff --git a/src/__tests__/auth.service.test.ts b/src/__tests__/auth.service.test.ts index c30a65f..10cddb6 100644 --- a/src/__tests__/auth.service.test.ts +++ b/src/__tests__/auth.service.test.ts @@ -1,10 +1,72 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { verifyAccessToken, hashToken } from "../services/auth"; import jwt from "jsonwebtoken"; import { config } from "../config/env"; +import prisma from "../config/database"; + +// Prefix for fixtures so cleanup never touches real data. +const N = "auth_svc_test_"; + +let testUserId: number; +let testUsername: string; +let testRoleName: string | null; + +beforeAll(async () => { + // Clean up any leftover fixtures from a previous failed run. + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); + + // Use a real (active, unlocked) user from the seeded DB so loadAuthData + // returns a full AuthData. We don't need a specific role — any active user + // works — but we record its role so we can assert roleName precisely. + const role = await prisma.roles.findFirst(); + if (!role) throw new Error("Test setup: no roles found — seed the database"); + + const user = await prisma.users.create({ + data: { + username: `${N}user`, + email: `${N}user@test.local`, + // DUMMY_HASH used elsewhere in the suite; we never verify a password + // here, only that the token's `sub` resolves to this user. + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Auth", + last_name: "Service", + is_active: true, + role_id: role.id, + }, + include: { roles: true }, + }); + testUserId = user.id; + testUsername = user.username; + testRoleName = user.roles?.name ?? null; +}); + +afterAll(async () => { + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); +}); describe("auth service", () => { describe("verifyAccessToken", () => { + it("returns AuthData for a valid token signed for a real user", async () => { + const token = jwt.sign( + { sub: testUserId, username: testUsername, role: testRoleName }, + config.jwt.secret, + { expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" }, + ); + + const result = await verifyAccessToken(token); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(testUserId); + expect(result!.username).toBe(testUsername); + expect(result!.roleName).toBe(testRoleName); + // Permissions resolve to an array (admins get all, others get role perms). + expect(Array.isArray(result!.permissions)).toBe(true); + }); + it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => { const consoleSpy = vi .spyOn(console, "error") @@ -35,6 +97,14 @@ describe("auth service", () => { }); describe("hashToken", () => { + it("matches the known SHA-256 vector for a fixed input", () => { + // SHA-256("hello") — pins the algorithm so a silent change (e.g. to a + // different but still 64-hex-deterministic hash) is caught. + expect(hashToken("hello")).toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); + it("produces deterministic SHA-256 hex output", () => { const t1 = hashToken("hello"); const t2 = hashToken("hello"); diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index dab78d9..c18142f 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -1,12 +1,73 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import bcrypt from "bcryptjs"; import { buildApp, extractCookie } from "./helpers"; +import prisma from "../config/database"; +import { config } from "../config/env"; let app: Awaited>; +// 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 }, name: string) { + const cookies = res.headers["set-cookie"]; + if (!cookies) return undefined; + const arr = Array.isArray(cookies) ? cookies : [cookies]; + return arr.find((c: string) => c.startsWith(`${name}=`)); +} + beforeAll(async () => { app = await buildApp(); + + // Clean up any leftover fixture from a previous failed run, plus its tokens. + const leftovers = await prisma.users.findMany({ + where: { username: { startsWith: N } }, + select: { id: true }, + }); + if (leftovers.length) { + await prisma.refresh_tokens.deleteMany({ + where: { user_id: { in: leftovers.map((u) => u.id) } }, + }); + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + } + + const role = await prisma.roles.findFirst(); + if (!role) throw new Error("Test setup: no roles found — seed the database"); + + // Hash the known password with the SAME bcrypt cost the app uses so login's + // bcrypt.compare succeeds. + const passwordHash = await bcrypt.hash( + TEST_PASSWORD, + config.security.bcryptCost, + ); + + const user = await prisma.users.create({ + data: { + username: TEST_USERNAME, + email: `${N}user@test.local`, + password_hash: passwordHash, + first_name: "Auth", + last_name: "Test", + is_active: true, + totp_enabled: false, + role_id: role.id, + }, + }); + testUserId = user.id; }); + afterAll(async () => { + await prisma.refresh_tokens + .deleteMany({ where: { user_id: testUserId } }) + .catch(() => {}); + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); await app.close(); }); @@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => { }); expect(res.statusCode).toBe(400); }); + + it("returns 401 for a valid user with the wrong password", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/login", + payload: { username: TEST_USERNAME, password: "definitely-wrong" }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().success).toBe(false); + }); + + it("returns 200 with an access token and a refresh cookie on valid credentials", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/login", + payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(typeof body.data.access_token).toBe("string"); + expect(body.data.access_token.length).toBeGreaterThan(0); + expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry); + expect(body.data.user.userId).toBe(testUserId); + expect(body.data.user.username).toBe(TEST_USERNAME); + + // The refresh token must be set as an httpOnly, SameSite=Strict cookie. + const cookie = extractCookie(res, "refresh_token"); + expect(cookie).toBeTruthy(); + const rawCookie = rawSetCookie(res, "refresh_token")!; + expect(rawCookie).toMatch(/HttpOnly/i); + expect(rawCookie).toMatch(/SameSite=Strict/i); + }); }); describe("POST /api/admin/refresh", () => { @@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => { }); expect(res.statusCode).toBe(401); }); + + it("returns 401 for an invalid/unknown refresh token", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/refresh", + cookies: { refresh_token: "not-a-real-token" }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().success).toBe(false); + }); + + it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => { + // First log in to obtain a valid refresh cookie. + const loginRes = await app.inject({ + method: "POST", + url: "/api/admin/login", + payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, + }); + expect(loginRes.statusCode).toBe(200); + const originalRefresh = extractCookie(loginRes, "refresh_token")!; + expect(originalRefresh).toBeTruthy(); + + const refreshRes = await app.inject({ + method: "POST", + url: "/api/admin/refresh", + cookies: { refresh_token: originalRefresh }, + }); + expect(refreshRes.statusCode).toBe(200); + const body = refreshRes.json(); + expect(body.success).toBe(true); + expect(typeof body.data.access_token).toBe("string"); + expect(body.data.user.userId).toBe(testUserId); + + // The refresh token must be ROTATED: a new cookie value, different from + // the one we presented. + const rotated = extractCookie(refreshRes, "refresh_token"); + expect(rotated).toBeTruthy(); + expect(rotated).not.toBe(originalRefresh); + + // Reuse-detection: presenting the now-consumed original token must fail. + const reuseRes = await app.inject({ + method: "POST", + url: "/api/admin/refresh", + cookies: { refresh_token: originalRefresh }, + }); + expect(reuseRes.statusCode).toBe(401); + }); }); describe("POST /api/admin/logout", () => { - it("clears refresh token cookie", async () => { + it("clears the refresh token cookie", async () => { + // Log in so logout has a real token to delete. + const loginRes = await app.inject({ + method: "POST", + url: "/api/admin/login", + payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, + }); + const refresh = extractCookie(loginRes, "refresh_token")!; + const res = await app.inject({ method: "POST", url: "/api/admin/logout", + cookies: { refresh_token: refresh }, }); - expect(res.statusCode).toBeLessThan(500); + expect(res.statusCode).toBe(200); + + // The response must actively clear the cookie — an expiry in the past + // and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past). + const raw = rawSetCookie(res, "refresh_token"); + expect(raw).toBeTruthy(); + const cleared = + /Max-Age=0/i.test(raw!) || + /Expires=Thu, 01 Jan 1970/i.test(raw!) || + /^refresh_token=;/.test(raw!); + expect(cleared).toBe(true); + + // The deleted token must no longer be usable for refresh. + const reuseRes = await app.inject({ + method: "POST", + url: "/api/admin/refresh", + cookies: { refresh_token: refresh }, + }); + expect(reuseRes.statusCode).toBe(401); }); }); diff --git a/src/__tests__/customers.schema.test.ts b/src/__tests__/customers.schema.test.ts index 3ac8261..615e337 100644 --- a/src/__tests__/customers.schema.test.ts +++ b/src/__tests__/customers.schema.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { UpdateCustomerSchema } from "../schemas/customers.schema"; +import { + CreateCustomerSchema, + UpdateCustomerSchema, +} from "../schemas/customers.schema"; describe("UpdateCustomerSchema", () => { it("rejects empty name", () => { @@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => { const result = UpdateCustomerSchema.safeParse({ street: "Main St" }); expect(result.success).toBe(true); }); + + it("rejects a name longer than 255 chars", () => { + const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) }); + expect(result.success).toBe(false); + }); + + it("accepts nullable address/identity fields set to null", () => { + const result = UpdateCustomerSchema.safeParse({ + street: null, + city: null, + postal_code: null, + country: null, + company_id: null, + vat_id: null, + }); + expect(result.success).toBe(true); + }); +}); + +describe("CreateCustomerSchema", () => { + it("requires a name", () => { + const result = CreateCustomerSchema.safeParse({ street: "Main St" }); + expect(result.success).toBe(false); + }); + + it("accepts a full valid payload with all optional fields", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + street: "Main St 1", + city: "Praha", + postal_code: "11000", + country: "CZ", + company_id: "12345678", + vat_id: "CZ12345678", + custom_fields: [{ label: "Note", value: "VIP" }], + customer_field_order: ["name", "street"], + }); + expect(result.success).toBe(true); + }); + + it("accepts custom_fields as an array of arbitrary objects", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + custom_fields: [ + { label: "a", value: 1 }, + { nested: { deep: true } }, + "raw string", + ], + }); + expect(result.success).toBe(true); + }); + + it("rejects custom_fields that is not an array", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + custom_fields: { not: "an array" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects custom_fields longer than the 100-item cap", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })), + }); + expect(result.success).toBe(false); + }); + + it("rejects customer_field_order entries that are not strings", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + customer_field_order: ["name", 5], + }); + expect(result.success).toBe(false); + }); + + it("rejects an over-length nullable field (vat_id > 255)", () => { + const result = CreateCustomerSchema.safeParse({ + name: "Acme Corp", + vat_id: "x".repeat(256), + }); + expect(result.success).toBe(false); + }); }); diff --git a/src/__tests__/env.test.ts b/src/__tests__/env.test.ts index 83b4b85..95fca2f 100644 --- a/src/__tests__/env.test.ts +++ b/src/__tests__/env.test.ts @@ -32,3 +32,37 @@ describe("env validation", () => { expect(config.db.url).toBeTruthy(); }); }); + +describe("env validation — failure path", () => { + // The config singleton runs its validation at module-load time and is then + // cached by the module system, so we cannot re-import it with bad env to + // observe a throw without complex module-registry resetting. Instead we test + // the exact predicates config/env.ts uses to reject bad input — this is the + // logic that protects the boot, and the loaded config above proves the + // happy path. (Predicates kept in sync with src/config/env.ts.) + const HEX64_RE = /^[0-9a-fA-F]{64}$/; + + it("rejects a too-short / non-hex JWT_SECRET", () => { + expect(HEX64_RE.test("short")).toBe(false); + expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short + expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex + // The real config's secret must pass the very same check. + expect(HEX64_RE.test(config.jwt.secret)).toBe(true); + }); + + it("rejects an out-of-range or non-numeric PORT", () => { + const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535; + expect(portValid(parseInt("0", 10))).toBe(false); + expect(portValid(parseInt("70000", 10))).toBe(false); + expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN + expect(portValid(config.port)).toBe(true); + }); + + it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => { + const expiryValid = (n: number) => !Number.isNaN(n) && n > 0; + expect(expiryValid(0)).toBe(false); + expect(expiryValid(-1)).toBe(false); + expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN + expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true); + }); +}); diff --git a/src/__tests__/exchange-rates.test.ts b/src/__tests__/exchange-rates.test.ts index 4ea603a..1938756 100644 --- a/src/__tests__/exchange-rates.test.ts +++ b/src/__tests__/exchange-rates.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { toCzk, getRate } from "../services/exchange-rates"; +import { + toCzk, + getRate, + __resetRateCacheForTest, +} from "../services/exchange-rates"; // Mock global fetch const mockFetch = vi.fn(); @@ -8,6 +12,11 @@ global.fetch = mockFetch; describe("exchange-rates", () => { beforeEach(() => { mockFetch.mockReset(); + // The rate cache is module-level and persists across tests. Without this + // reset, the first test to populate "today" would short-circuit every + // later mockFetch resolution (cache hit), making assertions order-dependent + // and asserting stale rates. Reset it so each test gets a clean fetch. + __resetRateCacheForTest(); }); describe("toCzk", () => { @@ -43,6 +52,21 @@ describe("exchange-rates", () => { const result = await toCzk(100, "EUR"); expect(result).toBe(2500); }); + + it("honours amount != 1 (currency quoted per 100 units)", async () => { + // CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate` + // is for `amount` units, so the per-unit rate is rate/amount. Here the + // per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK. + // If the service ignored `amount` it would compute 100*250 = 25000. + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }], + }), + }); + const result = await toCzk(100, "HUF"); + expect(result).toBe(250); + }); }); describe("getRate", () => { @@ -60,5 +84,17 @@ describe("exchange-rates", () => { }); await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/); }); + + it("returns the per-unit rate for amount != 1", async () => { + // rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit). + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }], + }), + }); + const result = await getRate("HUF"); + expect(result).toBe(2.5); + }); }); }); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 2988f6f..ad1e085 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -13,13 +13,31 @@ export async function buildApp() { return app; } -export function extractCookie(response: any, name: string): string | undefined { +/** + * Minimal structural shape of the only thing extractCookie needs from a + * response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse` + * (from `app.inject`) and a plain supertest response without coupling to either. + */ +interface ResponseWithCookies { + headers: Record; +} + +export function extractCookie( + response: ResponseWithCookies, + name: string, +): string | undefined { const cookies = response.headers["set-cookie"]; if (!cookies) return undefined; const arr = Array.isArray(cookies) ? cookies : [cookies]; for (const c of arr) { + if (typeof c !== "string") continue; if (c.startsWith(`${name}=`)) { - return c.split(";")[0].split("=")[1]; + // The value is everything from the first "=" up to the first ";". + // Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g. + // padding) is not truncated — `split("=")[1]` would drop everything after + // the second "=". + const pair = c.split(";")[0]; + return pair.slice(pair.indexOf("=") + 1); } } return undefined; diff --git a/src/__tests__/invoices.service.test.ts b/src/__tests__/invoices.service.test.ts index 58931d0..a2d4b4b 100644 --- a/src/__tests__/invoices.service.test.ts +++ b/src/__tests__/invoices.service.test.ts @@ -1,20 +1,45 @@ import { describe, it, expect } from "vitest"; +import { Prisma } from "@prisma/client"; 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", () => { it("calculates subtotal without VAT when apply_vat is false", () => { - const inv = { - apply_vat: false, - vat_rate: { toNumber: () => 21 }, - currency: "CZK", - invoice_items: [ - { - quantity: { toNumber: () => 2 }, - unit_price: { toNumber: () => 100 }, - vat_rate: { toNumber: () => 21 }, - }, - ], - }; + const inv = buildInvoice({ + applyVat: false, + vatRate: 21, + items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }], + }); expect(invoiceTotalWithVat(inv)).toBe(200); }); @@ -24,32 +49,72 @@ describe("invoiceTotalWithVat", () => { // Total VAT = 7.00 * 3 = 21.00 // Subtotal = 33.33 * 3 = 99.99 // Total = 99.99 + 21.00 = 120.99 - const inv = { - apply_vat: true, - vat_rate: { toNumber: () => 21 }, - currency: "CZK", - invoice_items: Array.from({ length: 3 }, () => ({ - quantity: { toNumber: () => 1 }, - unit_price: { toNumber: () => 33.33 }, - vat_rate: { toNumber: () => 21 }, + const inv = buildInvoice({ + applyVat: true, + vatRate: 21, + items: Array.from({ length: 3 }, () => ({ + quantity: 1, + unit_price: 33.33, + vat_rate: 21, })), - }; + }); expect(invoiceTotalWithVat(inv)).toBe(120.99); }); - it("handles null quantity and unit_price gracefully", () => { - const inv = { - apply_vat: true, - vat_rate: { toNumber: () => 21 }, - currency: "CZK", - invoice_items: [ - { - quantity: { toNumber: () => 0 }, - unit_price: { toNumber: () => 0 }, - vat_rate: { toNumber: () => 21 }, - }, + it("treats a null quantity (and null unit_price) as 0 for that line", () => { + // The line is genuinely null here (not 0): quantity?.toNumber() === undefined + // -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed + // line proves the null line contributes nothing while the rest still totals. + const inv = buildInvoice({ + applyVat: true, + vatRate: 21, + items: [ + { quantity: null, unit_price: null, vat_rate: 21 }, + { quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21 ], - }; + }); + expect(invoiceTotalWithVat(inv)).toBe(121); + }); + + it("returns 0 when every line is null", () => { + const inv = buildInvoice({ + applyVat: true, + vatRate: 21, + items: [{ quantity: null, unit_price: null, vat_rate: null }], + }); expect(invoiceTotalWithVat(inv)).toBe(0); }); + + it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => { + // Line 1: 2 x 100 @ 21% -> base 200, vat 42 + // Line 2: 1 x 50 @ 12% -> base 50, vat 6 + // Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice + // default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15 + // Subtotal = 350, VAT = 63, Total = 413 + const inv = buildInvoice({ + applyVat: true, + vatRate: 15, + items: [ + { quantity: 2, unit_price: 100, vat_rate: 21 }, + { quantity: 1, unit_price: 50, vat_rate: 12 }, + { quantity: 1, unit_price: 100, vat_rate: 0 }, + ], + }); + expect(invoiceTotalWithVat(inv)).toBe(413); + }); + + it("handles a negative (discount) line correctly", () => { + // Line 1: 1 x 1000 @ 21% -> base 1000, vat 210 + // Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42 + // Subtotal = 800, VAT = 168, Total = 968 + const inv = buildInvoice({ + applyVat: true, + vatRate: 21, + items: [ + { quantity: 1, unit_price: 1000, vat_rate: 21 }, + { quantity: 1, unit_price: -200, vat_rate: 21 }, + ], + }); + expect(invoiceTotalWithVat(inv)).toBe(968); + }); }); diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts index 4aef2d3..9f99b59 100644 --- a/src/__tests__/manual-create.test.ts +++ b/src/__tests__/manual-create.test.ts @@ -25,6 +25,15 @@ const baseOrder = { exchange_rate: 1, }; +/** + * Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently + * passed the test when a create failed) while still narrowing the union via the + * `in` operator so the success-branch fields stay typed. + */ +function failResult(res: unknown): never { + throw new Error(`expected a success result, got ${JSON.stringify(res)}`); +} + describe("createOrder (manual, optional linked project)", () => { it("creates an order AND a project sharing one number when create_project=true", async () => { const res = await createOrder({ @@ -32,9 +41,8 @@ describe("createOrder (manual, optional linked project)", () => { scope_title: "Ruční zakázka", create_project: true, }); - expect("id" in res).toBe(true); - if (!("id" in res)) return; - createdOrderIds.push(res.id); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); expect(res.project_id).toBeTruthy(); const project = await prisma.projects.findUnique({ where: { id: res.project_id! }, @@ -46,9 +54,8 @@ describe("createOrder (manual, optional linked project)", () => { it("creates only the order when create_project=false", async () => { const res = await createOrder({ ...baseOrder, create_project: false }); - expect("id" in res).toBe(true); - if (!("id" in res)) return; - createdOrderIds.push(res.id); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); expect(res.project_id).toBeUndefined(); }); }); @@ -56,19 +63,25 @@ describe("createOrder (manual, optional linked project)", () => { describe("shared pool coherence (tracking)", () => { it("order → standalone project → order produce three distinct shared numbers", async () => { const o1 = await createOrder({ ...baseOrder, create_project: true }); - if ("id" in o1) { - createdOrderIds.push(o1.id); - if (o1.project_id) createdProjectIds.push(o1.project_id); - } - const p = await createProject({ name: "Mezi-projekt", status: "aktivni" }); - if ("project_number" in p) createdProjectIds.push(p.id); - const o2 = await createOrder({ ...baseOrder, create_project: false }); - if ("id" in o2) createdOrderIds.push(o2.id); + if (!("id" in o1)) failResult(o1); + createdOrderIds.push(o1.id!); + if (o1.project_id) createdProjectIds.push(o1.project_id); - const n1 = "id" in o1 ? o1.order_number : null; - const np = "project_number" in p ? p.project_number : null; - const n2 = "id" in o2 ? o2.order_number : null; - expect(new Set([n1, np, n2]).size).toBe(3); + const p = await createProject({ name: "Mezi-projekt", status: "aktivni" }); + if (!("project_number" in p)) failResult(p); + createdProjectIds.push(p.id); + + const o2 = await createOrder({ ...baseOrder, create_project: false }); + if (!("id" in o2)) failResult(o2); + createdOrderIds.push(o2.id!); + + // All three numbers must be present (non-null) AND distinct. + 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", status: "aktivni", }); - expect("project_number" in result).toBe(true); - if (!("project_number" in result)) return; + if (!("project_number" in result)) failResult(result); createdProjectIds.push(result.id); expect(typeof result.project_number).toBe("string"); expect(result.project_number!.length).toBeGreaterThan(0); @@ -112,9 +124,8 @@ describe("createOrder with price line item + attachment", () => { }, ], }); - expect("id" in res).toBe(true); - if (!("id" in res)) return; - createdOrderIds.push(res.id); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); const items = await prisma.order_items.findMany({ where: { order_id: res.id }, }); @@ -122,21 +133,25 @@ describe("createOrder with price line item + attachment", () => { expect(Number(items[0].unit_price)).toBe(12345); }); - it("stores an uploaded PO attachment", async () => { - const buf = Buffer.from("%PDF-1.4 fake"); + it("stores an uploaded PO attachment with the exact bytes", async () => { + const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí"); const res = await createOrder( { ...baseOrder, create_project: false }, buf, "po.pdf", ); - expect("id" in res).toBe(true); - if (!("id" in res)) return; - createdOrderIds.push(res.id); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); const order = await prisma.orders.findUnique({ where: { id: res.id }, select: { attachment_name: true, attachment_data: true }, }); expect(order?.attachment_name).toBe("po.pdf"); + // Verify the stored content/size, not just truthiness: round-trip the + // bytes and compare length + value against what we uploaded. expect(order?.attachment_data).toBeTruthy(); + const stored = Buffer.from(order!.attachment_data!); + expect(stored.length).toBe(buf.length); + expect(stored.equals(buf)).toBe(true); }); }); diff --git a/src/__tests__/nas-file-manager.test.ts b/src/__tests__/nas-file-manager.test.ts index 172030e..cf14637 100644 --- a/src/__tests__/nas-file-manager.test.ts +++ b/src/__tests__/nas-file-manager.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import { NasFileManager } from "../services/nas-file-manager"; describe("NasFileManager path traversal", () => { @@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => { }); }); }); + +/** + * Positive-path coverage: a legitimate (non-traversal) path inside a real + * project folder is ACCEPTED — it passes resolveProjectPath validation and + * reaches the underlying fs op (success → null). Without this, a + * "reject-everything" regression in the traversal guard would still pass every + * rejection test above. Uses the constructor basePath seam + a temp dir so it + * is deterministic even where the real NAS (Z:) is not mounted. + */ +describe("NasFileManager accepts legitimate paths", () => { + let tmpBase: string; + let nas: NasFileManager; + const NUM = "29990001"; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-")); + nas = new NasFileManager(tmpBase); + // Create a real project folder _ 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); + }); +}); diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index 9c9316f..08a72f5 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -9,6 +9,49 @@ import { } from "../services/numbering.service"; import prisma from "../config/database"; +const YEAR = new Date().getFullYear(); + +/** + * Assert two consecutive document numbers (a) keep an identical format / year + * prefix and (b) increment by exactly one — without hard-coding which + * `company_settings` pattern is configured. + * + * Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of + * "trailing digit run" is ambiguous. Instead we find the longest common prefix + * of the two strings (the unchanged format/year portion) and parse the + * remaining differing suffixes as integers — these are the rendered sequences. + */ +function assertStrictIncrement(num1: string, num2: string) { + expect(num1).not.toBe(num2); + expect(num2.length).toBe(num1.length); // same width ⇒ same format + + let i = 0; + while (i < num1.length && num1[i] === num2[i]) i++; + const commonPrefix = num1.slice(0, i); + // The format/year portion is shared and non-empty (the numbers don't differ + // from the very first character). + expect(commonPrefix.length).toBeGreaterThan(0); + + const seq1 = Number(num1.slice(i)); + const seq2 = Number(num2.slice(i)); + expect(Number.isNaN(seq1)).toBe(false); + expect(Number.isNaN(seq2)).toBe(false); + expect(seq2).toBe(seq1 + 1); // strict +1, not merely !== +} + +/** + * Seed the (type, year) sequence row to a high `last_number` so the next + * generated numbers land far above any real order/quotation in the test DB — + * the generator's skip-taken retry then never fires and the strict +1 + * assertion is deterministic. + */ +async function seedSequence(type: string, lastNumber: number) { + await prisma.number_sequences.deleteMany({ where: { type } }); + await prisma.number_sequences.create({ + data: { type, year: YEAR, last_number: lastNumber }, + }); +} + describe("generateSharedNumber", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); @@ -24,10 +67,13 @@ describe("generateSharedNumber", () => { expect(num.length).toBeGreaterThan(0); }); - it("increments on consecutive calls", async () => { + it("increments the sequence by exactly 1 and preserves the year/format", async () => { + // Seed high so neither generated number collides with a real order/project + // → the skip-taken retry never fires and the increment is strictly +1. + await seedSequence("shared", 900000); const num1 = await generateSharedNumber(); const num2 = await generateSharedNumber(); - expect(num1).not.toBe(num2); + assertStrictIncrement(num1, num2); }); }); @@ -46,10 +92,11 @@ describe("generateOfferNumber", () => { expect(num.length).toBeGreaterThan(0); }); - it("increments on consecutive calls", async () => { + it("increments the sequence by exactly 1 and preserves the year/format", async () => { + await seedSequence("offer", 900000); const num1 = await generateOfferNumber(); const num2 = await generateOfferNumber(); - expect(num1).not.toBe(num2); + assertStrictIncrement(num1, num2); }); }); @@ -77,9 +124,17 @@ describe("previewSharedNumber", () => { }); it("skips a number already taken when the sequence counter lags behind", async () => { - // Fresh counter → preview points at the first sequence number. With the - // skip-taken logic this is guaranteed free, so we can safely claim it. + // Seed the counter HIGH so the preview lands far above any real + // order/project in the test DB. Without this, a real row could already + // hold the first sequence number — making the create collide on the unique + // column, or making `second !== first` pass for the wrong reason (because + // the counter happened to already point past `first`). + await seedSequence("shared", 900000); const first = await previewSharedNumber(); + // Preconditions: the seeded preview really is free (so the create below is + // safe) and is exactly what the generator would assign next. + expect(await isSharedNumberTaken(first)).toBe(false); + const project = await prisma.projects.create({ data: { project_number: first, name: "test-preview-skip-taken" }, }); @@ -117,9 +172,14 @@ describe("previewOfferNumber", () => { }); it("skips a number already taken when the sequence counter lags behind", async () => { - // Fresh counter → preview points at the first sequence number; with the - // skip-taken logic it's guaranteed free, so we can claim it. + // Seed HIGH so the preview lands above any real quotation in the test DB + // (see the shared-number test for the rationale): the create can't collide + // and the skip-advance is genuinely exercised rather than passing because a + // real row already advanced the preview. + await seedSequence("offer", 900000); const first = await previewOfferNumber(); + expect(await isOfferNumberTaken(first)).toBe(false); + const quotation = await prisma.quotations.create({ data: { quotation_number: first }, }); diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index 66b2a5e..e5b841b 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -7,6 +7,7 @@ import prisma from "../config/database"; import { config } from "../config/env"; import { securityHeaders } from "../middleware/security"; import planRoutes from "../routes/admin/plan"; +import { localDateStr } from "../utils/date"; import { resolveCell, resolveGrid, @@ -31,6 +32,14 @@ import { const N = "wh_plan_"; let adminUserId: number; +// A DEDICATED, distinct non-admin user for the service-level employee-scoping +// tests (and as the second employee in the multi-user bulk test). Creating our +// own user — instead of grabbing "the second user in the DB" — guarantees it is +// never accidentally the admin (which would make `toEqual([])` pass for the +// wrong reason on a single-user DB), and that it is genuinely distinct so the +// 2-employee aggregate assertion can't collapse to 1. +let scopeUserId: number; +let scopeRoleId: number; let noPermUserId: number; beforeAll(async () => { @@ -43,11 +52,44 @@ beforeAll(async () => { if (!admin) throw new Error("Test setup: admin user not found"); adminUserId = admin.id; - // For list-scoping tests: any non-admin user that is NOT the admin we use - // for create tests. Just pick the second user. - const allUsers = await prisma.users.findMany({ take: 2 }); - noPermUserId = - allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id; + // Dedicated non-admin user + role for scoping/multi-user tests. + const stamp = Date.now(); + const role = await prisma.roles.create({ + data: { + name: `scope_plan_${stamp}`, + display_name: "Plan Scope Test", + }, + }); + scopeRoleId = role.id; + const user = await prisma.users.create({ + data: { + username: `scope_plan_${stamp}`, + first_name: "Scope", + last_name: "User", + email: `scope_plan_${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + role_id: role.id, + is_active: true, + }, + }); + scopeUserId = user.id; + if (scopeUserId === adminUserId) + throw new Error("scope user must be distinct from admin"); +}); + +afterAll(async () => { + // Clean up the dedicated scope user/role created above. Wrapped in catch so a + // leftover FK (none expected — its rows are note-prefixed and removed) can't + // mask real failures. + if (scopeUserId) + await prisma.users + .deleteMany({ where: { id: scopeUserId } }) + .catch(() => {}); + if (scopeRoleId) + await prisma.roles + .deleteMany({ where: { id: scopeRoleId } }) + .catch(() => {}); }); beforeEach(async () => { @@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => { const result = assertNotPastDate("2000-01-01", true); expect(result).toBeNull(); }); + + it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => { + // Deterministic without mocking the clock: compute "today" exactly the way + // the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in + // config/env.ts). The boundary is the bug-prone path — an off-by-one here + // would wrongly reject editing today's plan. + const today = localDateStr(new Date()); + expect(assertNotPastDate(today, false)).toBeNull(); + }); + + it("rejects YESTERDAY (local Prague date) as a past date", () => { + const d = new Date(); + d.setDate(d.getDate() - 1); // local-time yesterday + const yesterday = localDateStr(d); + const result = assertNotPastDate(yesterday, false); + expect(result).not.toBeNull(); + expect(result?.status).toBe(403); + }); }); // --------------------------------------------------------------------------- @@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => { ); expect("data" in updated).toBe(true); if ("data" in updated) { - expect(updated.data.note).toBe(`${N}updated`); + expect((updated.data as any).note).toBe(`${N}updated`); expect((updated.oldData as any).note).toBe(`${N}original`); } }); @@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => { ); expect("data" in updated).toBe(true); if ("data" in updated) { - expect(updated.data.note).toBe(`${N}updated`); + expect((updated.data as any).note).toBe(`${N}updated`); expect((updated.oldData as any).note).toBe(`${N}original`); } }); @@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => { ); const rows = await listEntries( { user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" }, - noPermUserId, + scopeUserId, // dedicated distinct non-admin actor (never the admin) false, // not admin ); + // A non-admin actor querying ANOTHER user's data is scoped to its own + // user_id, so it sees none of the admin's entries. This can only be + // [] for the right reason because scopeUserId !== adminUserId (asserted + // in beforeAll). expect(rows).toEqual([]); }); }); @@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => { ); const rows = await listOverrides( { user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" }, - noPermUserId, + scopeUserId, // dedicated distinct non-admin actor (never the admin) false, ); expect(rows).toEqual([]); @@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => { }); it("aggregates across multiple employees", async () => { + // Two genuinely distinct employees (admin + the dedicated scope user) so + // `users` can't collapse to 1 if the DB happened to have a single user. const res = await bulkCreateEntries( { - user_ids: [adminUserId, noPermUserId], + user_ids: [adminUserId, scopeUserId], date_from: "2096-08-03", // Friday date_to: "2096-08-03", include_weekends: true, diff --git a/src/__tests__/planCategory.test.ts b/src/__tests__/planCategory.test.ts index 64d69d3..8ccb9a6 100644 --- a/src/__tests__/planCategory.test.ts +++ b/src/__tests__/planCategory.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import prisma from "../config/database"; import { listPlanCategories, @@ -12,10 +12,28 @@ import { const created: number[] = []; +// Every test category's slug starts with "test_" AND contains "_zz" (labels all +// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids — +// means a previous crashed run can't leave a `test_kategorie_zz` row behind that +// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow +// pattern avoids touching real seed categories (work/other/…). +async function cleanupByPrefix() { + await prisma.plan_categories.deleteMany({ + where: { key: { startsWith: "test_", contains: "_zz" } }, + }); +} + +beforeAll(async () => { + await cleanupByPrefix(); +}); + afterAll(async () => { if (created.length) { await prisma.plan_categories.deleteMany({ where: { id: { in: created } } }); } + // Belt-and-suspenders: also remove anything matching the slug prefix in case + // a test threw before pushing its id into `created`. + await cleanupByPrefix(); await prisma.$disconnect(); }); @@ -24,6 +42,13 @@ describe("slugifyCategory", () => { expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz"); expect(slugifyCategory("Příprava")).toBe("priprava"); }); + + it("collapses an all-symbol label to an empty slug", () => { + // No alphanumerics survive → empty string. createPlanCategory's uniqueKey + // then falls back to "kategorie" so the row is still creatable. + expect(slugifyCategory("***")).toBe(""); + expect(slugifyCategory(" / ")).toBe(""); + }); }); describe("plan category service", () => { diff --git a/src/__tests__/received-invoices-vat.test.ts b/src/__tests__/received-invoices-vat.test.ts index 598ccfd..9c394d7 100644 --- a/src/__tests__/received-invoices-vat.test.ts +++ b/src/__tests__/received-invoices-vat.test.ts @@ -23,4 +23,29 @@ describe("vatFromGross (VAT contained in a gross total)", () => { it("returns 0 for a zero amount", () => { expect(vatFromGross(0, 21)).toBe(0); }); + + it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => { + // 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent + // up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of- + // round regression (which would yield 20.04) fails. + expect(vatFromGross(115.5, 21)).toBe(20.05); + // 95.70 @ 21% -> raw 16.609090... rounds up to 16.61. + expect(vatFromGross(95.7, 21)).toBe(16.61); + }); + + it("rounds the cent DOWN when below the half-cent", () => { + // 100.40 @ 21% -> raw 17.424793... rounds down to 17.42. + expect(vatFromGross(100.4, 21)).toBe(17.42); + }); + + it("pins the result to the exact stored 2-dp value (not just close)", () => { + // The DB column is Decimal(_, 2). vatFromGross must already return a value + // with no third-decimal float drift. Strict equality + a 2-dp self-check, + // NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail. + const v = vatFromGross(1210, 21); + expect(v).toBe(210); // exact, not ~210 + expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean + // The real-world figure too: pinned exactly, replacing the toBeCloseTo above. + expect(vatFromGross(22542.91, 21)).toBe(3912.41); + }); }); diff --git a/src/__tests__/schema-nan.test.ts b/src/__tests__/schema-nan.test.ts index bf496f3..abf47f9 100644 --- a/src/__tests__/schema-nan.test.ts +++ b/src/__tests__/schema-nan.test.ts @@ -1,8 +1,21 @@ import { describe, it, expect } from "vitest"; import { CreateOrderSchema } from "../schemas/orders.schema"; import { CreateQuotationSchema } from "../schemas/offers.schema"; +import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema"; +import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema"; +import { + ReceiptItemSchema, + CreateSupplierSchema, +} from "../schemas/warehouse.schema"; +import { UpdateCompanySettingsSchema } from "../schemas/settings.schema"; -describe("Zod NaN rejection", () => { +// These schemas now build on the shared form-coercion helpers in +// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …). +// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers +// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN +// string. The original suite only asserted (b); these tests pin (a) as well. + +describe("Zod form coercion + NaN rejection", () => { describe("CreateOrderSchema", () => { it("rejects NaN string in quantity", () => { const result = CreateOrderSchema.safeParse({ @@ -27,6 +40,21 @@ describe("Zod NaN rejection", () => { }); expect(result.success).toBe(true); }); + + it("coerces a valid numeric STRING quantity/unit_price to a number", () => { + const result = CreateOrderSchema.safeParse({ + customer_id: 1, + items: [{ quantity: "2", unit_price: "100.5" }], + }); + expect(result.success).toBe(true); + if (result.success) { + const item = result.data.items![0]; + expect(item.quantity).toBe(2); + expect(typeof item.quantity).toBe("number"); + expect(item.unit_price).toBe(100.5); + expect(typeof item.unit_price).toBe("number"); + } + }); }); describe("CreateQuotationSchema", () => { @@ -46,6 +74,18 @@ describe("Zod NaN rejection", () => { 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", () => { const result = CreateQuotationSchema.safeParse({ customer_id: 1, @@ -53,5 +93,215 @@ describe("Zod NaN rejection", () => { }); expect(result.success).toBe(false); }); + + it("coerces a valid numeric STRING item quantity/unit_price", () => { + const result = CreateQuotationSchema.safeParse({ + customer_id: 1, + items: [{ quantity: "3", unit_price: "250" }], + }); + expect(result.success).toBe(true); + if (result.success) { + const item = result.data.items![0]; + expect(item.quantity).toBe(3); + expect(item.unit_price).toBe(250); + } + }); + }); + + // The remaining schemas were the ones flagged as accepting NaN before the + // shared helpers landed. Assert both the coercion and the rejection paths. + + describe("CreateTripSchema", () => { + it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => { + const result = CreateTripSchema.safeParse({ + vehicle_id: "5", + trip_date: "2026-01-15", + start_km: "100", + end_km: "150.5", + route_from: "Praha", + route_to: "Brno", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.vehicle_id).toBe(5); + expect(result.data.start_km).toBe(100); + expect(result.data.end_km).toBe(150.5); + expect(typeof result.data.start_km).toBe("number"); + } + }); + + it("rejects a NaN string in start_km", () => { + const result = CreateTripSchema.safeParse({ + vehicle_id: 5, + trip_date: "2026-01-15", + start_km: "not-a-number", + end_km: 150, + route_from: "Praha", + route_to: "Brno", + }); + expect(result.success).toBe(false); + }); + + it("rejects a NaN / non-positive string in vehicle_id (FK)", () => { + const result = CreateTripSchema.safeParse({ + vehicle_id: "abc", + trip_date: "2026-01-15", + start_km: 100, + end_km: 150, + route_from: "Praha", + route_to: "Brno", + }); + expect(result.success).toBe(false); + }); + }); + + describe("CreateReceivedInvoiceSchema", () => { + it("coerces numeric STRING month / year / amount to numbers", () => { + const result = CreateReceivedInvoiceSchema.safeParse({ + supplier_name: "ACME s.r.o.", + month: "6", + year: "2026", + amount: "1210.50", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.month).toBe(6); + expect(result.data.year).toBe(2026); + expect(result.data.amount).toBe(1210.5); + expect(typeof result.data.amount).toBe("number"); + } + }); + + it("rejects a NaN string in amount", () => { + const result = CreateReceivedInvoiceSchema.safeParse({ + supplier_name: "ACME s.r.o.", + month: 6, + year: 2026, + amount: "not-money", + }); + expect(result.success).toBe(false); + }); + + it("rejects an out-of-range month (numberInRange 1-12)", () => { + const result = CreateReceivedInvoiceSchema.safeParse({ + supplier_name: "ACME s.r.o.", + month: 13, + year: 2026, + }); + expect(result.success).toBe(false); + }); + + it("rejects a NaN string in month", () => { + const result = CreateReceivedInvoiceSchema.safeParse({ + supplier_name: "ACME s.r.o.", + month: "bad", + year: 2026, + }); + expect(result.success).toBe(false); + }); + }); + + describe("warehouse ReceiptItemSchema", () => { + it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => { + const result = ReceiptItemSchema.safeParse({ + item_id: "7", + quantity: "12.5", + unit_price: "99", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.item_id).toBe(7); + expect(result.data.quantity).toBe(12.5); + expect(result.data.unit_price).toBe(99); + expect(typeof result.data.quantity).toBe("number"); + } + }); + + it("rejects a NaN string in quantity", () => { + const result = ReceiptItemSchema.safeParse({ + item_id: 7, + quantity: "lots", + unit_price: 99, + }); + expect(result.success).toBe(false); + }); + + it("rejects a non-positive quantity (positiveNumberFromForm)", () => { + const result = ReceiptItemSchema.safeParse({ + item_id: 7, + quantity: 0, + unit_price: 99, + }); + expect(result.success).toBe(false); + }); + + it("rejects a NaN / non-positive string in item_id (FK)", () => { + const result = ReceiptItemSchema.safeParse({ + item_id: "abc", + quantity: 5, + unit_price: 99, + }); + expect(result.success).toBe(false); + }); + }); +}); + +// Regression tests for the 2026-06-09 audit fix pass: the schema hardening +// must NOT reject input the app legitimately sends. +describe("schema hardening — does not reject previously-valid input", () => { + it("settings: optional email fields accept an empty string (un-filled)", () => { + const r = UpdateCompanySettingsSchema.safeParse({ + invoice_alert_email: "", + leave_notify_email: "", + smtp_from: "", + }); + expect(r.success).toBe(true); + }); + + it("settings: a valid email is accepted, a malformed one rejected", () => { + expect( + UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success, + ).toBe(true); + expect( + UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" }) + .success, + ).toBe(false); + }); + + it("warehouse supplier: 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, + ); }); }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 1d2989f..3f7533c 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,2 +1,44 @@ import dotenv from "dotenv"; -dotenv.config({ path: ".env.test" }); + +const ENV_TEST_PATH = ".env.test"; + +// vitest.config.ts already loads .env.test with override:true; we reload here so +// the setup file is self-contained when run directly. +dotenv.config({ path: ENV_TEST_PATH }); + +/** + * Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and + * MUTATES it, so it must never run against a dev/production DB. We require the + * configured database name to contain "test" (the committed `.env.test` points + * at the throwaway `app_test`); anything else is a HARD FAILURE before any test + * runs, so a stray DATABASE_URL can't silently corrupt dev/prod data. + */ +function extractDbName(url: string | undefined): string | null { + if (!url) return null; + try { + // mysql://user:pass@host:3306/dbname?params → "dbname" + const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, ""); + const dbName = afterAuthority.split("?")[0].split("/")[0]; + return dbName || null; + } catch { + return null; + } +} + +const dbUrl = process.env.DATABASE_URL; +const dbName = extractDbName(dbUrl); + +if (!dbUrl) { + throw new Error( + "[test-setup] DATABASE_URL is not set. The suite mutates a real database — " + + `point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`, + ); +} + +if (dbName == null || !/test/i.test(dbName)) { + throw new Error( + `[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` + + `whose name does not contain "test". The suite mutates the database — point ` + + `${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`, + ); +} diff --git a/src/__tests__/warehouse.test.ts b/src/__tests__/warehouse.test.ts index cde5f2e..2f058f6 100644 --- a/src/__tests__/warehouse.test.ts +++ b/src/__tests__/warehouse.test.ts @@ -627,19 +627,22 @@ describe("Item CRUD", () => { expect(body.data.name).toBe(`${N}item1_updated`); }); - it("deactivates an item (soft delete)", async () => { + it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => { + // The current API HARD-deletes the item — the route does + // `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The + // test name reflects that real behavior so a future intended switch to + // soft-delete would (correctly) make this test fail and prompt a review, + // rather than the old "soft delete" name masking the mismatch. const res = await app.inject({ method: "DELETE", url: `/api/admin/warehouse/items/${createdItemId}`, headers: authHeader(adminToken), }); - // The current API hard-deletes the item (the route does - // `prisma.sklad_items.delete`, not an `is_active=false` toggle). - // Verify the row is gone rather than checking is_active. expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); + // The row is actually gone — a subsequent GET 404s. const checkRes = await app.inject({ method: "GET", url: `/api/admin/warehouse/items/${createdItemId}`, @@ -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 // --------------------------------------------------------------------------- diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 4dcd98f..0ceab7a 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -81,6 +81,15 @@ export default function AdminApp() { {import.meta.env.DEV && UiKit && ( } /> )} + {/* + Auth gating is delegated to : 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 wrapper + (like `login`/`ui-kit` above) is PUBLIC by default — wrap it + in AppShell or add its own guard if it needs auth. + */} }> } /> } /> diff --git a/src/admin/components/AlertContainer.tsx b/src/admin/components/AlertContainer.tsx index a4dc50b..66ac6b6 100644 --- a/src/admin/components/AlertContainer.tsx +++ b/src/admin/components/AlertContainer.tsx @@ -12,7 +12,10 @@ export default function AlertContainer() { key={alert.id} open 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 }} > - {form.user_ids.length === users.length + {users.length > 0 && form.user_ids.length === users.length ? "Odznačit vše" : "Vybrat vše"} diff --git a/src/admin/components/ErrorBoundary.tsx b/src/admin/components/ErrorBoundary.tsx index 3eaaebb..248265b 100644 --- a/src/admin/components/ErrorBoundary.tsx +++ b/src/admin/components/ErrorBoundary.tsx @@ -19,6 +19,9 @@ export default class ErrorBoundary extends Component { } 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); } diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx index 92985e8..3cb2570 100644 --- a/src/admin/components/OrderConfirmationModal.tsx +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; @@ -48,17 +48,33 @@ export default function OrderConfirmationModal({ const [items, setItems] = useState(initialItems); 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 () => { setLoading(true); try { await onGenerate(lang, applyVatState, undefined); + // Only close on success — a generation error must keep the modal open. + onClose(); } catch (err) { console.error("Chyba při generování potvrzení:", err); alert.error("Nepodařilo se vygenerovat potvrzení"); } finally { setLoading(false); - setStep("choose"); - onClose(); } }; @@ -66,13 +82,13 @@ export default function OrderConfirmationModal({ setLoading(true); try { await onGenerate(lang, applyVatState, items); + // Only close on success — on error keep the user's edited items intact. + onClose(); } catch (err) { console.error("Chyba při generování potvrzení:", err); alert.error("Nepodařilo se vygenerovat potvrzení"); } finally { setLoading(false); - setStep("choose"); - onClose(); } }; diff --git a/src/admin/components/PlanCategoriesModal.tsx b/src/admin/components/PlanCategoriesModal.tsx index 6691dab..de7b68c 100644 --- a/src/admin/components/PlanCategoriesModal.tsx +++ b/src/admin/components/PlanCategoriesModal.tsx @@ -145,6 +145,10 @@ export default function PlanCategoriesModal({ aria-label={`Barva – ${c.label}`} /> { diff --git a/src/admin/components/PlanCellModal.tsx b/src/admin/components/PlanCellModal.tsx index 8dd408d..3619a50 100644 --- a/src/admin/components/PlanCellModal.tsx +++ b/src/admin/components/PlanCellModal.tsx @@ -28,6 +28,35 @@ interface Project { 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 = | { kind: "closed" } | { kind: "create"; userId: number; date: string } @@ -74,11 +103,11 @@ interface Props { projects: Project[]; categories: PlanCategory[]; onClose: () => void; - onSaveEntry: (body: any) => Promise; - onUpdateEntry: (id: number, body: any) => Promise; + onSaveEntry: (body: PlanEntryCreateBody) => Promise; + onUpdateEntry: (id: number, body: PlanEntryUpdateBody) => Promise; onDeleteEntry: (id: number) => Promise; - onSaveOverride: (body: any) => Promise; - onUpdateOverride: (id: number, body: any) => Promise; + onSaveOverride: (body: PlanOverrideCreateBody) => Promise; + onUpdateOverride: (id: number, body: PlanOverrideUpdateBody) => Promise; onDeleteOverride: (id: number) => Promise; onCreateOverrideFromRange: ( entryId: number, @@ -128,6 +157,71 @@ export default function PlanCellModal(props: Props) { return ; } +interface EditFormInitial { + date_from: string; + date_to: string; + is_range: boolean; + project_id: number | null; + category: string; + note: string; +} + +/** + * Initial form values for every mode kind. Unhandled kinds get a harmless + * default (EditForm renders null for them — but only AFTER its hooks have run). + * Keeping this a plain function (not an inline IIFE with early returns) lets + * EditForm call its useState hooks unconditionally, satisfying the Rules of + * Hooks. + */ +function computeEditFormInitial( + mode: PlanCellModalMode, + activeCategories: { key: string }[], +): EditFormInitial { + if (mode.kind === "create" || mode.kind === "create-override") { + return { + date_from: mode.date, + date_to: mode.date, + is_range: false, + project_id: null, + // Default to "work" when it's active, otherwise the first active + // category, so a new entry never starts on a retired key. + category: activeCategories.some((c) => c.key === "work") + ? "work" + : (activeCategories[0]?.key ?? "work"), + note: "", + }; + } + if (mode.kind === "edit-range") { + return { + date_from: mode.range.date_from, + date_to: mode.range.date_to, + is_range: mode.range.date_from !== mode.range.date_to, + project_id: mode.range.project_id, + category: mode.range.category, + note: mode.range.note, + }; + } + if (mode.kind === "edit-override") { + return { + date_from: mode.date, + date_to: mode.date, + is_range: false, + project_id: mode.cell.project_id, + category: mode.cell.category, + 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, @@ -140,13 +234,27 @@ function EditForm(props: Props) { 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 [dateTo, setDateTo] = useState(initial.date_to); + const [isRange, setIsRange] = useState(initial.is_range); + const [projectId, setProjectId] = useState(initial.project_id); + const [category, setCategory] = useState(initial.category); + const [note, setNote] = useState(initial.note); - // 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. + // 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" && @@ -156,55 +264,6 @@ function EditForm(props: Props) { 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. - const isOverride = - mode.kind === "edit-override" || mode.kind === "create-override"; - const activeCategories = props.categories.filter((c) => c.is_active); - - const initial = (() => { - if (mode.kind === "create" || mode.kind === "create-override") { - return { - date_from: mode.date, - date_to: mode.date, - is_range: false, - project_id: null as number | null, - // Default to "work" when it's active, otherwise the first active - // category, so a new entry never starts on a retired key. - category: activeCategories.some((c) => c.key === "work") - ? "work" - : (activeCategories[0]?.key ?? "work"), - note: "", - }; - } - if (mode.kind === "edit-range") { - return { - date_from: mode.range.date_from, - date_to: mode.range.date_to, - is_range: mode.range.date_from !== mode.range.date_to, - project_id: mode.range.project_id, - category: mode.range.category, - note: mode.range.note, - }; - } - // mode.kind === "edit-override" (narrowed above) - return { - date_from: mode.date, - date_to: mode.date, - is_range: false, - project_id: mode.cell.project_id, - category: mode.cell.category, - note: mode.cell.note, - }; - })(); - - const [dateFrom, setDateFrom] = useState(initial.date_from); - const [dateTo, setDateTo] = useState(initial.date_to); - const [isRange, setIsRange] = useState(initial.is_range); - const [projectId, setProjectId] = useState(initial.project_id); - const [category, setCategory] = useState(initial.category); - const [note, setNote] = useState(initial.note); - const title = mode.kind === "create" ? "Nový záznam plánu" diff --git a/src/admin/components/PlanGrid.tsx b/src/admin/components/PlanGrid.tsx index 9d942c5..0b3e4e9 100644 --- a/src/admin/components/PlanGrid.tsx +++ b/src/admin/components/PlanGrid.tsx @@ -466,9 +466,23 @@ export default function PlanGrid({ }: Props) { const today = useMemo(() => todayIso(), []); 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(); + 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 ; - const days = eachDay(data.date_from, data.date_to); const users = data.users; // pulseKey?.nonce is included in the data-pulse attribute so a second // mutation on the same cell re-triggers the animation (CSS animations @@ -610,12 +624,9 @@ export default function PlanGrid({ cell={c} project={ c.project_id - ? (projects.find( - (p) => p.id === c.project_id, - ) ?? null) + ? (projectMap.get(c.project_id) ?? null) : null } - readonly={!canEdit} categoryLabel={planCategoryLabel( c.category, catMap, diff --git a/src/admin/components/PlanRangeChips.tsx b/src/admin/components/PlanRangeChips.tsx index c1723a7..55a177c 100644 --- a/src/admin/components/PlanRangeChips.tsx +++ b/src/admin/components/PlanRangeChips.tsx @@ -4,17 +4,18 @@ import type { Project } from "../lib/queries/projects"; interface Props { cell: ResolvedCell | null; project: Project | null; - readonly?: boolean; 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 )} {hasPermission("orders.edit") && - order.valid_transitions?.filter((s) => s !== "stornovana") - .length! > 0 && + (order.valid_transitions?.filter((s) => s !== "stornovana") + .length ?? 0) > 0 && order .valid_transitions!.filter((s) => s !== "stornovana") .map((status) => ( diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index 74f7537..b04ab4c 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -174,8 +174,6 @@ export default function ProjectDetail() { } }, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps - if (!hasPermission("projects.view")) return ; - const projectSaveMutation = useApiMutation< { name: string; @@ -212,6 +210,8 @@ export default function ProjectDetail() { invalidate: ["projects", "warehouse"], }); + if (!hasPermission("projects.view")) return ; + const updateForm = (field: keyof ProjectForm, value: string) => setForm((prev) => ({ ...prev, [field]: value })); diff --git a/src/admin/pages/Projects.tsx b/src/admin/pages/Projects.tsx index 8062d63..e0c53bd 100644 --- a/src/admin/pages/Projects.tsx +++ b/src/admin/pages/Projects.tsx @@ -9,6 +9,7 @@ import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { formatDate } from "../utils/formatters"; import useTableSort from "../hooks/useTableSort"; +import useDebounce from "../hooks/useDebounce"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { projectListOptions, @@ -117,6 +118,7 @@ export default function Projects() { const { sort, order, handleSort } = useTableSort("project_number"); const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); const [page, setPage] = useState(1); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteFiles, setDeleteFiles] = useState(false); @@ -216,7 +218,7 @@ export default function Projects() { isPending, isFetching, } = usePaginatedQuery( - projectListOptions({ search, sort, order, page }), + projectListOptions({ search: debouncedSearch, sort, order, page }), ); if (!hasPermission("projects.view")) return ; diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index d9c933c..6b9a9f8 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -10,6 +10,7 @@ import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import apiFetch from "../utils/api"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; +import { normalizeDateStr } from "../utils/attendanceHelpers"; import useTableSort from "../hooks/useTableSort"; import { companySettingsOptions, @@ -276,7 +277,14 @@ export default function ReceivedInvoices({ // Derive list data from query (paginatedJsonQuery returns { data, pagination }) 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 const stats = statsQuery.data ?? null; @@ -446,12 +454,11 @@ export default function ReceivedInvoices({ } }; - const toDateInput = (d: string | null | undefined): string => { - if (!d) return ""; - const date = new Date(d); - if (isNaN(date.getTime())) return ""; - return date.toISOString().split("T")[0]; - }; + // Seed edit-form date inputs from stored values. Use normalizeDateStr (pure + // string slice) instead of new Date(...).toISOString() — the latter converts + // to UTC and is a day early in the late-evening Prague window. + const toDateInput = (d: string | null | undefined): string => + normalizeDateStr(d); const openEdit = (inv: ReceivedInvoice) => { setEditInvoice({ diff --git a/src/admin/pages/Settings.tsx b/src/admin/pages/Settings.tsx index c661140..84191c5 100644 --- a/src/admin/pages/Settings.tsx +++ b/src/admin/pages/Settings.tsx @@ -311,11 +311,6 @@ export default function Settings() { setSysFormInitialized(true); }, [sysSettingsData, sysFormInitialized]); - // ── Early return after all hooks ── - if (!canAccessSettings) { - return ; - } - const saveSystemSettingsMutation = useApiMutation< typeof sysForm, { message?: string; error?: string } @@ -400,6 +395,11 @@ export default function Settings() { }, }); + // ── Early return after all hooks ── + if (!canAccessSettings) { + return ; + } + const handleSaveSystemSettings = async () => { try { await saveSystemSettingsMutation.mutateAsync(sysForm); diff --git a/src/admin/pages/Trips.tsx b/src/admin/pages/Trips.tsx index 6729285..b1667c3 100644 --- a/src/admin/pages/Trips.tsx +++ b/src/admin/pages/Trips.tsx @@ -185,8 +185,6 @@ export default function Trips() { const [errors, setErrors] = useState>({}); const [, setLastKm] = useState(0); - if (!hasPermission("trips.record")) return ; - const submitMutation = useApiMutation< TripForm, { message?: string; error?: string } @@ -214,6 +212,8 @@ export default function Trips() { }, }); + if (!hasPermission("trips.record")) return ; + const fetchLastKm = async (vehicleId: string) => { if (!vehicleId) { setLastKm(0); @@ -284,7 +284,7 @@ export default function Trips() { if ( form.start_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í"; } @@ -312,8 +312,8 @@ export default function Trips() { }; const calculateDistance = (): number => { - const start = parseInt(String(form.start_km)) || 0; - const end = parseInt(String(form.end_km)) || 0; + const start = parseInt(String(form.start_km), 10) || 0; + const end = parseInt(String(form.end_km), 10) || 0; return end > start ? end - start : 0; }; diff --git a/src/admin/pages/TripsAdmin.tsx b/src/admin/pages/TripsAdmin.tsx index 0bb2771..68ab05c 100644 --- a/src/admin/pages/TripsAdmin.tsx +++ b/src/admin/pages/TripsAdmin.tsx @@ -228,8 +228,6 @@ export default function TripsAdmin() { ); const trips = (tripsData ?? []).map(mapTrip); - if (!hasPermission("trips.manage")) return ; - // useApiMutation JSON.stringifies the whole TIn as the request body, so // TIn must match the backend schema (UpdateTripSchema) shape directly — // 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 ; + const openEditModal = (trip: Trip) => { setEditingTrip(trip); setEditForm({ diff --git a/src/admin/pages/Vehicles.tsx b/src/admin/pages/Vehicles.tsx index 058ca61..001c4e8 100644 --- a/src/admin/pages/Vehicles.tsx +++ b/src/admin/pages/Vehicles.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import Chip from "@mui/material/Chip"; import IconButton from "@mui/material/IconButton"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; @@ -19,6 +18,7 @@ import { Field, TextField, SwitchField, + StatusChip, LoadingState, PageEnter, type DataColumn, @@ -261,12 +261,10 @@ export default function Vehicles() { key: "status", header: "Stav", render: (v) => ( - toggleActive(v)} - sx={{ cursor: "pointer", fontWeight: 600 }} /> ), }, @@ -401,7 +399,10 @@ export default function Vehicles() { value={form.initial_km} slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }} onChange={(e) => - setForm({ ...form, initial_km: parseInt(e.target.value) || 0 }) + setForm({ + ...form, + initial_km: parseInt(e.target.value, 10) || 0, + }) } /> diff --git a/src/admin/pages/Warehouse.tsx b/src/admin/pages/Warehouse.tsx index cd76275..0e04c5b 100644 --- a/src/admin/pages/Warehouse.tsx +++ b/src/admin/pages/Warehouse.tsx @@ -10,6 +10,7 @@ import { warehouseReservationListOptions, warehouseMovementLogOptions, type WarehouseItem, + type MovementLogRow, } from "../lib/queries/warehouse"; import { formatCurrency, formatDate } from "../utils/formatters"; import { @@ -72,15 +73,8 @@ interface BelowMinRow { min_quantity: number; } -interface MovementRow { - _idx: number; - date: string; - type: string; - document_number: string; - item_name: string; - quantity: number; - supplier_or_project: string; -} +// Dashboard movement row = the shared lib row plus a stable list index for rowKey. +type MovementRow = MovementLogRow & { _idx: number }; export default function Warehouse() { const { hasPermission } = useAuth(); diff --git a/src/admin/pages/WarehouseCategories.tsx b/src/admin/pages/WarehouseCategories.tsx index 9c663cf..f53f677 100644 --- a/src/admin/pages/WarehouseCategories.tsx +++ b/src/admin/pages/WarehouseCategories.tsx @@ -99,7 +99,6 @@ export default function WarehouseCategories() { show: boolean; category: WarehouseCategory | null; }>({ show: false, category: null }); - const [saving, setSaving] = useState(false); const submitMutation = useApiMutation({ url: () => @@ -156,13 +155,10 @@ export default function WarehouseCategories() { setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; - setSaving(true); try { await submitMutation.mutateAsync(form); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); - } finally { - setSaving(false); } }; @@ -274,7 +270,7 @@ export default function WarehouseCategories() { onClose={() => setShowModal(false)} onSubmit={handleSubmit} title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"} - loading={saving} + loading={submitMutation.isPending} > + onChange={(e) => { + const n = Number(e.target.value); setForm({ ...form, - sort_order: parseInt(e.target.value) || 0, - }) - } + sort_order: Number.isFinite(n) ? n : 0, + }); + }} inputProps={{ min: 0 }} /> diff --git a/src/admin/pages/WarehouseInventory.tsx b/src/admin/pages/WarehouseInventory.tsx index 9509950..92bbe24 100644 --- a/src/admin/pages/WarehouseInventory.tsx +++ b/src/admin/pages/WarehouseInventory.tsx @@ -122,7 +122,12 @@ export default function WarehouseInventory() { header: "Položky", width: "20%", 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, + ), }, ]; diff --git a/src/admin/pages/WarehouseInventoryDetail.tsx b/src/admin/pages/WarehouseInventoryDetail.tsx index b5b2631..74d1962 100644 --- a/src/admin/pages/WarehouseInventoryDetail.tsx +++ b/src/admin/pages/WarehouseInventoryDetail.tsx @@ -67,13 +67,9 @@ export default function WarehouseInventoryDetail() { error, } = useQuery(warehouseInventoryDetailOptions(id)); - if (!hasPermission("warehouse.inventory")) return ; - - const confirmMutation = useApiMutation({ - url: () => - session - ? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm` - : "/api/admin/warehouse/inventory-sessions/0/confirm", + const confirmMutation = useApiMutation({ + url: (sessionId) => + `/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`, method: () => "POST", invalidate: ["warehouse"], 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 ; + const handleConfirm = async () => { if (!session) return; try { - await confirmMutation.mutateAsync(undefined); + await confirmMutation.mutateAsync(session.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } diff --git a/src/admin/pages/WarehouseInventoryForm.tsx b/src/admin/pages/WarehouseInventoryForm.tsx index 6956a91..368b1db 100644 --- a/src/admin/pages/WarehouseInventoryForm.tsx +++ b/src/admin/pages/WarehouseInventoryForm.tsx @@ -22,6 +22,12 @@ interface InventoryItem { 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 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< { 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 ; + + 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 () => { if (!validate()) return; setSaving(true); @@ -246,7 +259,7 @@ export default function WarehouseInventoryForm() { type="number" value={item.actual_qty || ""} onChange={(e) => - updateItem(index, "actual_qty", Number(e.target.value)) + updateItem(index, "actual_qty", parseDecimal(e.target.value)) } inputProps={{ min: 0, diff --git a/src/admin/pages/WarehouseIssueDetail.tsx b/src/admin/pages/WarehouseIssueDetail.tsx index 81babfa..348b0a3 100644 --- a/src/admin/pages/WarehouseIssueDetail.tsx +++ b/src/admin/pages/WarehouseIssueDetail.tsx @@ -70,15 +70,8 @@ export default function WarehouseIssueDetail() { error, } = useQuery(warehouseIssueDetailOptions(id)); - if (!hasPermission("warehouse.view")) return ; - - const canOperate = hasPermission("warehouse.operate"); - - const confirmMutation = useApiMutation({ - url: () => - issue - ? `/api/admin/warehouse/issues/${issue.id}/confirm` - : "/api/admin/warehouse/issues/0/confirm", + const confirmMutation = useApiMutation({ + url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`, method: () => "POST", invalidate: ["warehouse"], onSuccess: () => { @@ -86,11 +79,8 @@ export default function WarehouseIssueDetail() { }, }); - const cancelMutation = useApiMutation({ - url: () => - issue - ? `/api/admin/warehouse/issues/${issue.id}/cancel` - : "/api/admin/warehouse/issues/0/cancel", + const cancelMutation = useApiMutation({ + url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`, method: () => "POST", invalidate: ["warehouse"], 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 ; + + const canOperate = hasPermission("warehouse.operate"); + const handleConfirm = async () => { if (!issue) return; try { - await confirmMutation.mutateAsync(undefined); + await confirmMutation.mutateAsync(issue.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } @@ -111,7 +106,7 @@ export default function WarehouseIssueDetail() { const handleCancel = async () => { if (!issue) return; try { - await cancelMutation.mutateAsync(undefined); + await cancelMutation.mutateAsync(issue.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } @@ -315,8 +310,8 @@ export default function WarehouseIssueDetail() { {iss.project ? ( , + { id?: number } + >({ + url: () => + isEdit + ? `/api/admin/warehouse/issues/${id}` + : "/api/admin/warehouse/issues", + method: () => (isEdit ? "PUT" : "POST"), + invalidate: ["warehouse"], + }); + + const confirmMutation = useApiMutation({ + 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 ; const addItem = () => { @@ -332,31 +354,6 @@ export default function WarehouseIssueForm() { })), }); - const saveMutation = useApiMutation< - ReturnType, - { id?: number } - >({ - url: () => - isEdit - ? `/api/admin/warehouse/issues/${id}` - : "/api/admin/warehouse/issues", - method: () => (isEdit ? "PUT" : "POST"), - invalidate: ["warehouse"], - }); - - const [confirmIssueId, setConfirmIssueId] = useState(null); - const confirmMutation = useApiMutation({ - 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 => { try { const data = await saveMutation.mutateAsync(buildPayload()); @@ -370,13 +367,10 @@ export default function WarehouseIssueForm() { }; const confirmIssue = async (issueId: number) => { - setConfirmIssueId(issueId); try { - await confirmMutation.mutateAsync(undefined); + await confirmMutation.mutateAsync(issueId); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); - } finally { - setConfirmIssueId(null); } }; diff --git a/src/admin/pages/WarehouseItemDetail.tsx b/src/admin/pages/WarehouseItemDetail.tsx index a8cba4e..65becef 100644 --- a/src/admin/pages/WarehouseItemDetail.tsx +++ b/src/admin/pages/WarehouseItemDetail.tsx @@ -154,13 +154,6 @@ export default function WarehouseItemDetail() { useModalLock(editing); - if (!hasPermission("warehouse.view")) return ; - - const canManage = hasPermission("warehouse.manage"); - - const updateForm = (field: keyof ItemForm, value: string | boolean) => - setForm((prev) => ({ ...prev, [field]: value })); - const saveMutation = useApiMutation< Record, { 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 ; + + const canManage = hasPermission("warehouse.manage"); + + const updateForm = (field: keyof ItemForm, value: string | boolean) => + setForm((prev) => ({ ...prev, [field]: value })); + const handleSave = async () => { const newErrors: Record = {}; if (!form.name.trim()) newErrors.name = "Zadejte název položky"; diff --git a/src/admin/pages/WarehouseLocations.tsx b/src/admin/pages/WarehouseLocations.tsx index fb33e05..b3f8edd 100644 --- a/src/admin/pages/WarehouseLocations.tsx +++ b/src/admin/pages/WarehouseLocations.tsx @@ -233,13 +233,8 @@ export default function WarehouseLocations() { } const total = locations.length; - const subtitle = `${total} ${ - total === 1 - ? "umístění" - : total >= 2 && total <= 4 - ? "umístění" - : "umístění" - }`; + // "umístění" is invariant across grammatical number in Czech — no plural form. + const subtitle = `${total} umístění`; const columns: DataColumn[] = [ { diff --git a/src/admin/pages/WarehouseReceiptDetail.tsx b/src/admin/pages/WarehouseReceiptDetail.tsx index 472888b..97915ab 100644 --- a/src/admin/pages/WarehouseReceiptDetail.tsx +++ b/src/admin/pages/WarehouseReceiptDetail.tsx @@ -94,15 +94,8 @@ export default function WarehouseReceiptDetail() { error, } = useQuery(warehouseReceiptDetailOptions(id)); - if (!hasPermission("warehouse.view")) return ; - - const canOperate = hasPermission("warehouse.operate"); - - const confirmMutation = useApiMutation({ - url: () => - receipt - ? `/api/admin/warehouse/receipts/${receipt.id}/confirm` - : "/api/admin/warehouse/receipts/0/confirm", + const confirmMutation = useApiMutation({ + url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`, method: () => "POST", invalidate: ["warehouse"], onSuccess: () => { @@ -110,11 +103,8 @@ export default function WarehouseReceiptDetail() { }, }); - const cancelMutation = useApiMutation({ - url: () => - receipt - ? `/api/admin/warehouse/receipts/${receipt.id}/cancel` - : "/api/admin/warehouse/receipts/0/cancel", + const cancelMutation = useApiMutation({ + url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/cancel`, method: () => "POST", invalidate: ["warehouse"], onSuccess: () => { @@ -124,13 +114,11 @@ export default function WarehouseReceiptDetail() { }); const deleteAttachmentMutation = useApiMutation< - { attachmentId: number }, + { receiptId: number; attachmentId: number }, void >({ - url: ({ attachmentId }) => - receipt - ? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}` - : `/api/admin/warehouse/receipts/0/attachments/0`, + url: ({ receiptId, attachmentId }) => + `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`, method: () => "DELETE", invalidate: ["warehouse"], 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 ; + + const canOperate = hasPermission("warehouse.operate"); + const handleConfirm = async () => { if (!receipt) return; try { - await confirmMutation.mutateAsync(undefined); + await confirmMutation.mutateAsync(receipt.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } @@ -150,7 +143,7 @@ export default function WarehouseReceiptDetail() { const handleCancel = async () => { if (!receipt) return; try { - await cancelMutation.mutateAsync(undefined); + await cancelMutation.mutateAsync(receipt.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } @@ -159,7 +152,10 @@ export default function WarehouseReceiptDetail() { const handleDeleteAttachment = async (attachmentId: number) => { if (!receipt) return; try { - await deleteAttachmentMutation.mutateAsync({ attachmentId }); + await deleteAttachmentMutation.mutateAsync({ + receiptId: receipt.id, + attachmentId, + }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } diff --git a/src/admin/pages/WarehouseReceiptForm.tsx b/src/admin/pages/WarehouseReceiptForm.tsx index b6f4758..f71ff3f 100644 --- a/src/admin/pages/WarehouseReceiptForm.tsx +++ b/src/admin/pages/WarehouseReceiptForm.tsx @@ -200,6 +200,75 @@ export default function WarehouseReceiptForm() { } }, [isEdit, receipt]); + const saveMutation = useApiMutation< + ReturnType, + { id?: number } + >({ + url: () => + isEdit + ? `/api/admin/warehouse/receipts/${id}` + : "/api/admin/warehouse/receipts", + method: () => (isEdit ? "PUT" : "POST"), + invalidate: ["warehouse"], + }); + + const confirmMutation = useApiMutation({ + 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 ; const addItem = () => { @@ -266,31 +335,6 @@ export default function WarehouseReceiptForm() { })), }); - const saveMutation = useApiMutation< - ReturnType, - { id?: number } - >({ - url: () => - isEdit - ? `/api/admin/warehouse/receipts/${id}` - : "/api/admin/warehouse/receipts", - method: () => (isEdit ? "PUT" : "POST"), - invalidate: ["warehouse"], - }); - - const [confirmReceiptId, setConfirmReceiptId] = useState(null); - const confirmMutation = useApiMutation({ - 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 => { try { const data = await saveMutation.mutateAsync(buildPayload()); @@ -304,13 +348,10 @@ export default function WarehouseReceiptForm() { }; const confirmReceipt = async (receiptId: number) => { - setConfirmReceiptId(receiptId); try { - await confirmMutation.mutateAsync(undefined); + await confirmMutation.mutateAsync(receiptId); } catch (e) { 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) { return ; } diff --git a/src/admin/pages/WarehouseReports.tsx b/src/admin/pages/WarehouseReports.tsx index 11a5478..8deeea4 100644 --- a/src/admin/pages/WarehouseReports.tsx +++ b/src/admin/pages/WarehouseReports.tsx @@ -8,11 +8,14 @@ import { warehouseStockStatusOptions, warehouseBelowMinimumOptions, warehouseCategoryListOptions, + warehouseMovementLogOptions, type WarehouseItem, + type MovementLogRow, } from "../lib/queries/warehouse"; -import apiFetch from "../utils/api"; -import { formatCurrency } from "../utils/formatters"; +import { jsonQuery } from "../lib/apiAdapter"; +import { formatCurrency, formatDate } from "../utils/formatters"; import { + Alert, Button, Card, DataTable, @@ -43,16 +46,6 @@ interface ProjectConsumptionRow { 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[] = [ { value: "stock-status", label: "Stav zásob" }, { value: "project-consumption", label: "Spotřeba projektů" }, @@ -260,34 +253,36 @@ function ProjectConsumptionTab({ name: string; }[]) ?? []; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [fetched, setFetched] = useState(false); + // Filters applied on "Zobrazit"; the query key carries them so results land in + // the ["warehouse"] cache and refresh on warehouse mutations. + const [applied, setApplied] = useState<{ + projectId: number | ""; + dateFrom: string; + dateTo: string; + } | null>(null); - const handleFetch = async () => { - setLoading(true); - setFetched(true); - try { + const { + data, + isFetching: loading, + error, + } = useQuery({ + queryKey: ["warehouse", "project-consumption", applied], + enabled: applied !== null, + queryFn: () => { const params = new URLSearchParams(); - if (projectId) params.set("project_id", String(projectId)); - if (dateFrom) params.set("date_from", dateFrom); - if (dateTo) params.set("date_to", dateTo); + if (applied?.projectId) + params.set("project_id", String(applied.projectId)); + if (applied?.dateFrom) params.set("date_from", applied.dateFrom); + if (applied?.dateTo) params.set("date_to", applied.dateTo); const qs = params.toString(); - const response = await apiFetch( + return jsonQuery( `/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`, ); - const result = await response.json(); - if (result.success) { - setData(result.data ?? []); - } else { - setData([]); - } - } catch { - setData([]); - } finally { - setLoading(false); - } - }; + }, + }); + + const fetched = applied !== null; + const handleFetch = () => setApplied({ projectId, dateFrom, dateTo }); const columns: DataColumn[] = [ { @@ -352,7 +347,15 @@ function ProjectConsumptionTab({ {fetched && loading && } - {fetched && !loading && data && ( + {fetched && !loading && error && ( + + {error instanceof Error + ? error.message + : "Nepodařilo se načíst spotřebu projektů"} + + )} + + {fetched && !loading && !error && data && ( columns={columns} @@ -383,34 +386,29 @@ function MovementLogTab({ dateTo: string; setDateTo: (v: string) => void; }) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [fetched, setFetched] = useState(false); + // Filters applied on "Zobrazit"; uses the shared movement-log query option so + // results land in the ["warehouse"] cache and refresh on warehouse mutations. + const [applied, setApplied] = useState<{ + type: string; + dateFrom: string; + dateTo: string; + } | null>(null); - const handleFetch = async () => { - setLoading(true); - setFetched(true); - try { - const params = new URLSearchParams(); - if (type) params.set("type", type); - if (dateFrom) params.set("date_from", dateFrom); - if (dateTo) params.set("date_to", dateTo); - const qs = params.toString(); - const response = await apiFetch( - `/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`, - ); - const result = await response.json(); - if (result.success) { - setData(result.data ?? []); - } else { - setData([]); - } - } catch { - setData([]); - } finally { - setLoading(false); - } - }; + const { + data, + isFetching: loading, + error, + } = useQuery({ + ...warehouseMovementLogOptions({ + type: applied?.type || undefined, + date_from: applied?.dateFrom || undefined, + date_to: applied?.dateTo || undefined, + }), + enabled: applied !== null, + }); + + const fetched = applied !== null; + const handleFetch = () => setApplied({ type, dateFrom, dateTo }); const TYPE_LABEL: Record = { receipt: "Příjem", @@ -423,7 +421,7 @@ function MovementLogTab({ header: "Datum", width: "12%", mono: true, - render: (row) => row.date, + render: (row) => formatDate(row.date), }, { key: "type", @@ -501,7 +499,15 @@ function MovementLogTab({ {fetched && loading && } - {fetched && !loading && data && ( + {fetched && !loading && error && ( + + {error instanceof Error + ? error.message + : "Nepodařilo se načíst pohyby"} + + )} + + {fetched && !loading && !error && data && ( columns={columns} diff --git a/src/admin/pages/WarehouseReservations.tsx b/src/admin/pages/WarehouseReservations.tsx index 8827fdb..9bab1ed 100644 --- a/src/admin/pages/WarehouseReservations.tsx +++ b/src/admin/pages/WarehouseReservations.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; 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 { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; @@ -9,12 +9,12 @@ import IconButton from "@mui/material/IconButton"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import ItemPicker from "../components/warehouse/ItemPicker"; -import apiFetch from "../utils/api"; import { formatDate, czechPlural } from "../utils/formatters"; import { warehouseReservationListOptions, type WarehouseReservation, } from "../lib/queries/warehouse"; +import { useApiMutation } from "../lib/queries/mutations"; import { projectListOptions } from "../lib/queries/projects"; import { Button, @@ -106,11 +106,17 @@ interface ReservationForm { notes: string; } +interface CreateReservationPayload { + item_id: number; + project_id: number; + quantity: number; + notes: string | null; +} + export default function WarehouseReservations() { const navigate = useNavigate(); const alert = useAlert(); const { hasPermission } = useAuth(); - const queryClient = useQueryClient(); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); @@ -148,6 +154,29 @@ export default function WarehouseReservations() { }), ); + const createMutation = useApiMutation({ + 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({ + 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 ; const canOperate = hasPermission("warehouse.operate"); @@ -161,33 +190,25 @@ export default function WarehouseReservations() { const hasActiveFilters = statusFilter || projectId; 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í"); return; } setSaving(true); try { - const response = await apiFetch("/api/admin/warehouse/reservations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - item_id: form.item_id, - project_id: form.project_id, - quantity: form.quantity, - notes: form.notes.trim() || null, - }), + await createMutation.mutateAsync({ + item_id: form.item_id, + project_id: form.project_id, + quantity: form.quantity, + notes: form.notes.trim() || null, }); - const result = await response.json(); - if (result.success) { - 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í"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setSaving(false); } @@ -197,20 +218,9 @@ export default function WarehouseReservations() { if (!cancelTarget) return; setCancelling(true); try { - const response = await apiFetch( - `/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`, - { method: "POST" }, - ); - 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í"); + await cancelMutation.mutateAsync(cancelTarget.id); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setCancelling(false); } @@ -451,12 +461,13 @@ export default function WarehouseReservations() { + onChange={(e) => { + const n = Number(e.target.value); setForm((prev) => ({ ...prev, - quantity: Number(e.target.value), - })) - } + quantity: Number.isFinite(n) ? n : 0, + })); + }} inputProps={{ min: 0, step: 0.001 }} /> diff --git a/src/admin/pages/WarehouseSuppliers.tsx b/src/admin/pages/WarehouseSuppliers.tsx index 62cadf1..702e3e6 100644 --- a/src/admin/pages/WarehouseSuppliers.tsx +++ b/src/admin/pages/WarehouseSuppliers.tsx @@ -129,10 +129,6 @@ export default function WarehouseSuppliers() { show: boolean; supplier: WarehouseSupplier | null; }>({ show: false, supplier: null }); - const [saving, setSaving] = useState(false); - const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState< - number | null - >(null); const submitMutation = useApiMutation< 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< - { is_active: boolean }, + { id: number; is_active: boolean }, { message?: string } >({ - url: () => { - if (!toggleActiveSupplierId) return API_BASE; - return `${API_BASE}/${toggleActiveSupplierId}`; - }, + url: ({ id }) => `${API_BASE}/${id}`, method: () => "PUT", invalidate: ["warehouse"], }); @@ -210,13 +206,10 @@ export default function WarehouseSuppliers() { setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; - setSaving(true); try { await submitMutation.mutateAsync(form); } catch (e) { 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) => { - setToggleActiveSupplierId(supplier.id); try { await toggleActiveMutation.mutateAsync({ + id: supplier.id, is_active: !supplier.is_active, }); alert.success( @@ -243,8 +236,6 @@ export default function WarehouseSuppliers() { ); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); - } finally { - setToggleActiveSupplierId(null); } }; @@ -403,7 +394,7 @@ export default function WarehouseSuppliers() { onClose={() => setShowModal(false)} onSubmit={handleSubmit} title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"} - loading={saving} + loading={submitMutation.isPending} > void; }) { return ( - + {children} ); diff --git a/src/admin/ui/AppShell.tsx b/src/admin/ui/AppShell.tsx index 6daca5f..c3347c8 100644 --- a/src/admin/ui/AppShell.tsx +++ b/src/admin/ui/AppShell.tsx @@ -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 { motion } from "framer-motion"; import Box from "@mui/material/Box"; @@ -19,14 +19,24 @@ export default function AppShell() { const [mobileOpen, setMobileOpen] = useState(false); const [loggingOut, setLoggingOut] = useState(false); const location = useLocation(); + const logoutTimer = useRef>(); const handleLogout = useCallback(() => { setLoggingOut(true); setMobileOpen(false); 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]); + useEffect(() => { + return () => { + if (logoutTimer.current) clearTimeout(logoutTimer.current); + }; + }, []); + if (loading) { return ( @@ -138,6 +148,7 @@ export default function AppShell() { fill="none" stroke="currentColor" strokeWidth="2" + aria-hidden="true" > diff --git a/src/admin/ui/Card.tsx b/src/admin/ui/Card.tsx index fb47e55..e17c76f 100644 --- a/src/admin/ui/Card.tsx +++ b/src/admin/ui/Card.tsx @@ -1,10 +1,30 @@ 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 ( - {children} + {disableContentPadding ? ( + children + ) : ( + {children} + )} ); } diff --git a/src/admin/ui/Checkbox.tsx b/src/admin/ui/Checkbox.tsx index 37bf3d5..a144d18 100644 --- a/src/admin/ui/Checkbox.tsx +++ b/src/admin/ui/Checkbox.tsx @@ -7,10 +7,20 @@ export function CheckboxField({ label, checked, onChange, + disabled, + name, + id, + indeterminate, + required, }: { label: ReactNode; checked: boolean; onChange: (v: boolean) => void; + disabled?: boolean; + name?: string; + id?: string; + indeterminate?: boolean; + required?: boolean; }) { return ( onChange(e.target.checked)} + disabled={disabled} + name={name} + id={id} + indeterminate={indeterminate} + required={required} /> } label={label} + disabled={disabled} + required={required} /> ); } diff --git a/src/admin/ui/ConfirmDialog.tsx b/src/admin/ui/ConfirmDialog.tsx index 11be7e5..17a80b0 100644 --- a/src/admin/ui/ConfirmDialog.tsx +++ b/src/admin/ui/ConfirmDialog.tsx @@ -41,15 +41,31 @@ export default function ConfirmDialog({ // this the button would flash back to "Smazat"/confirmText). React // "adjust state from props during render": update only while open. Also keeps // 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 ( isOpen && (shown.title !== title || shown.message !== message || shown.confirmText !== confirmText || + shown.cancelText !== cancelText || + shown.confirmVariant !== confirmVariant || shown.loading !== loading) ) { - setShown({ title, message, confirmText, loading }); + setShown({ + title, + message, + confirmText, + cancelText, + confirmVariant, + loading, + }); } return ( @@ -66,12 +82,12 @@ export default function ConfirmDialog({ - {cancelText} + {shown.cancelText} {shown.loading ? "Zpracovávám…" : shown.confirmText} diff --git a/src/admin/ui/DataTable.tsx b/src/admin/ui/DataTable.tsx index 6034599..c8f8103 100644 --- a/src/admin/ui/DataTable.tsx +++ b/src/admin/ui/DataTable.tsx @@ -11,6 +11,9 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; 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 { key: string; @@ -78,8 +81,56 @@ export default function DataTable({ if (isMobile) { const actionCol = columns.find((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 ( + {onSort && sortCols.length > 0 && ( + + onSort(e.target.value)} + sx={{ flex: 1 }} + > + {sortCols.map((c) => ( + + {c.header} + + ))} + + sortBy && onSort(sortBy)} + size="small" + > + + + + )} {rows.map((row) => { const extra = rowSx?.(row); return ( @@ -255,6 +306,14 @@ export default function DataTable({ e.stopPropagation() + : undefined + } sx={{ fontSize: ".8rem", ...(c.bold ? { fontWeight: 500 } : {}), diff --git a/src/admin/ui/Field.tsx b/src/admin/ui/Field.tsx index e2d29d7..222b98d 100644 --- a/src/admin/ui/Field.tsx +++ b/src/admin/ui/Field.tsx @@ -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 Typography from "@mui/material/Typography"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -18,10 +24,44 @@ export function Field({ hint?: string; children: ReactNode; }) { + // Associate the