diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md index aa52e770..c9bd0214 100644 --- a/docs/FEATURE_PRIORITIZATION.md +++ b/docs/FEATURE_PRIORITIZATION.md @@ -26,7 +26,7 @@ | 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth | | 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth | | 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth | -| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts | +| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts | | 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth | | 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) | | 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) | diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index a078ccf6..2833ec24 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index 0db529fe..84e81461 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index f4feaee6..80b51f39 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 0cd62c6d..8ff483ba 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 80fb28ce..50f3c6df 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 1b239e83..ede6fff5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 310f2ce5..19477e67 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index 9f45bb9b..112e0ec4 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 6742d5c8..c2ae4657 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index bc920e8d..61338ef2 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index fb262589..d1aa8df6 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index dba22877..71bbcf52 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index c921fc40..1a45825b 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index c1d2bd63..65075a30 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index 73995d8f..0b402d6e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index c5f9d6e9..c436a8d8 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 121879f8..983bd92f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/dashboard/dashboard.visual.spec.ts b/e2e/dashboard/dashboard.visual.spec.ts index ab51543b..5bdc2e70 100644 --- a/e2e/dashboard/dashboard.visual.spec.ts +++ b/e2e/dashboard/dashboard.visual.spec.ts @@ -191,7 +191,10 @@ async function mockDashboardApi(page: Page, userRoles: string) { return; } - if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS }); + if (pathname.includes("/Dashboard/Stats")) { + const unassigned = userRoles === "Dispatcher" ? 0 : 11; + return fulfillJson(route, { data: { ...STATS, unassigned } }); + } if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD }); if (pathname.includes("/Dashboard/Performance")) return fulfillJson(route, { data: PERFORMANCE }); diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 69205a2e..9f87208e 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy await page.getByLabel("New total NTE").fill("125"); await page.getByLabel("Reason").fill("Replacement compressor"); - await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({ + await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({ name: "estimate.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\nmock estimate"), @@ -349,7 +349,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible(); const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" }); - await approvalRow.getByRole("button", { name: "Approve" }).click(); + await approvalRow.getByRole("button", { name: "Approve $25.00" }).click(); const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" }); await changesRow.getByRole("button", { name: "Reject" }).click(); @@ -360,7 +360,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" }); await approvedRow.getByRole("button", { name: "Revoke" }).click(); await page.getByLabel("Reason for revoking").fill("Approval was made in error."); - await page.getByRole("button", { name: "Revoke $125" }).click(); + await page.getByRole("button", { name: "Revoke $25.00" }).click(); await expect .poll(() => decisions) diff --git a/package-lock.json b/package-lock.json index 1fe6bc3d..69b1eb0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,63 +10,63 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", - "@fontsource/montserrat": "^5.2.8", + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/montserrat": "^5.3.0", "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/interaction": "^6.1.21", "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@generouted/react-router": "^1.20.2", - "@hookform/resolvers": "^5.4.0", - "@mui/icons-material": "^9.2.0", - "@mui/material": "^9.2.0", - "@sentry/react": "10.73.0", - "@tanstack/query-broadcast-client-experimental": "5.101.2", - "@tanstack/react-query": "5.101.2", - "@tanstack/react-query-devtools": "5.101.2", - "@testing-library/dom": "^10.4.1", + "@hookform/resolvers": "^5.9.1", + "@mui/icons-material": "9.2.0", + "@mui/material": "9.2.0", + "@sentry/react": "10.75.0", + "@tanstack/query-broadcast-client-experimental": "5.103.1", + "@tanstack/react-query": "5.103.1", + "@tanstack/react-query-devtools": "5.103.1", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "clsx": "^2.1.1", - "ky": "^2.0.2", - "lucide-react": "^1.24.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-hook-form": "^7.81.0", + "ky": "^2.1.0", + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-hook-form": "^7.88.0", "react-icons": "^5.7.0", - "react-router": "^8.2.0", + "react-router": "^8.4.0", "react-toastify": "^11.1.0", - "tailwind-merge": "^3.6.0", - "zod": "^4.4.3" + "tailwind-merge": "^3.7.0", + "zod": "^4.6.5" }, "devDependencies": { - "@commitlint/cli": "^21.2.1", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", - "@sentry/cli": "3.7.0", - "@tailwindcss/vite": "^4.3.2", - "@types/node": "^24.13.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", + "@sentry/cli": "3.8.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.13.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", "husky": "^9.1.7", "jsdom": "^29.1.1", - "lint-staged": "^17.0.8", - "prettier": "^3.9.5", - "tailwindcss": "^4.3.2", + "lint-staged": "^17.5.1", + "prettier": "^3.9.8", + "tailwindcss": "^4.3.3", "typescript": "~6.0.3", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.4", - "vitest": "^4.1.10" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=22.22.1" @@ -418,19 +418,43 @@ "specificity": "bin/cli.js" } }, - "node_modules/@commitlint/cli": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", - "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-conventional": "^21.2.0", - "@commitlint/format": "^21.2.0", - "@commitlint/lint": "^21.2.0", - "@commitlint/load": "^21.2.0", - "@commitlint/read": "^21.2.1", - "@commitlint/types": "^21.2.0", + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.3.tgz", + "integrity": "sha512-5yOX6IRjcrIReCxpyAd04hgI/yhUMpVBJ/ouwFV0wk9yx5+fYRDWZWJ5h55Tbedgfol+5m2mU5nqzbB8g3v0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-conventional": "^21.2.3", + "@commitlint/format": "^21.2.3", + "@commitlint/lint": "^21.2.3", + "@commitlint/load": "^21.2.3", + "@commitlint/read": "^21.2.3", + "@commitlint/types": "^21.2.3", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -442,13 +466,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", - "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.3.tgz", + "integrity": "sha512-EP2n5DN2GwRWNVrLX0Jx3mpQlW5PfFi9HnD9OJ8hN/H0xFqpCFyHeGDhg+0P8/21iMMcdkcQgbNqmVklebqYsQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "conventional-changelog-conventionalcommits": "^10.0.0" }, "engines": { @@ -456,13 +480,13 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", - "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.3.tgz", + "integrity": "sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "ajv": "^8.11.0" }, "engines": { @@ -470,13 +494,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", - "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.3.tgz", + "integrity": "sha512-gEjNKgRocbODI5/sMx2Qc9XOf2/zFTNV2aEK1QZ4gKDKxDC/VR7us3QtIX1C5d5xxHxx0I/tEbYbdSP1Am2LwA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0" }, "engines": { @@ -494,13 +518,13 @@ } }, "node_modules/@commitlint/format": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", - "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.3.tgz", + "integrity": "sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "picocolors": "^1.1.1" }, "engines": { @@ -508,13 +532,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", - "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.3.tgz", + "integrity": "sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "semver": "^7.6.0" }, "engines": { @@ -522,32 +546,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", - "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.3.tgz", + "integrity": "sha512-lUZcSuVLLEDkhge9ERk8Rp+FXQtrUhCK+h328BFnzKXrTuXGdqiYMEpTlNWdZaOrjyMhMJaz354ZaWEGJxcLeg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.2.0", - "@commitlint/parse": "^21.2.0", - "@commitlint/rules": "^21.2.0", - "@commitlint/types": "^21.2.0" + "@commitlint/is-ignored": "^21.2.3", + "@commitlint/parse": "^21.2.3", + "@commitlint/rules": "^21.2.3", + "@commitlint/types": "^21.2.3" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", - "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.3.tgz", + "integrity": "sha512-cDrL8lOo23Lk7knGzQdjbFcq7z2JgvjwV6PhbESVvEc+Fe21MeLjPKBJi8nKjg7j+jh79CVbVSUbZc104thXRw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.2.0", + "@commitlint/config-validator": "^21.2.3", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/resolve-extends": "^21.2.3", + "@commitlint/types": "^21.2.3", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -569,13 +593,13 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", - "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.3.tgz", + "integrity": "sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "conventional-changelog-angular": "^9.0.0", "conventional-commits-parser": "^7.0.0" }, @@ -584,14 +608,14 @@ } }, "node_modules/@commitlint/read": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", - "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.3.tgz", + "integrity": "sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" }, @@ -600,14 +624,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", - "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.3.tgz", + "integrity": "sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/config-validator": "^21.2.3", + "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -617,16 +641,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", - "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.3.tgz", + "integrity": "sha512-5+YnI/LuTx3JDl9GjqfP2z9x96M8KKDrd+4jOel/T0L1BB3oAmBlSN8xppf63QsudiVEgR8C0gRoAMrPVrms6g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.2.0", + "@commitlint/ensure": "^21.2.3", "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.2.0" + "@commitlint/types": "^21.2.3" }, "engines": { "node": ">=22.12.0" @@ -656,9 +680,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", - "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.3.tgz", + "integrity": "sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -697,15 +721,27 @@ } }, "node_modules/@conventional-changelog/template": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", - "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz", + "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==", "dev": true, "license": "MIT", "engines": { "node": ">=22" } }, + "node_modules/@corvu/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@corvu/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.11" + }, + "peerDependencies": { + "solid-js": "^1.8" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -846,37 +882,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1081,9 +1086,9 @@ } }, "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==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1138,9 +1143,9 @@ } }, "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==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1169,28 +1174,53 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@fontsource/dm-sans": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", - "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz", + "integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@fontsource/jetbrains-mono": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", - "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@fontsource/montserrat": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.8.tgz", - "integrity": "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", + "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -1263,15 +1293,113 @@ } }, "node_modules/@hookform/resolvers": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", - "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.1.tgz", + "integrity": "sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { - "react-hook-form": "^7.55.0" + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0 || ^18.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=6.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@humanfs/core": { @@ -1340,6 +1468,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internationalized/number": { + "version": "3.6.8", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz", + "integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1386,6 +1523,66 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@kobalte/core": { + "version": "0.13.14", + "resolved": "https://registry.npmjs.org/@kobalte/core/-/core-0.13.14.tgz", + "integrity": "sha512-SBVB1lnvYbYt2x3t7fG0lo5fGLEbHQ6PII3NDPva8vD5e2v53On84ZVQiuIk0XI/55R4GQhden7Yem+R/XR8uQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@internationalized/number": "^3.2.1", + "@kobalte/utils": "^0.9.2", + "@solid-primitives/props": "^3.1.8", + "@solid-primitives/resize-observer": "^2.0.26", + "solid-presence": "^0.2.0", + "solid-prevent-scroll": "^0.1.11" + }, + "peerDependencies": { + "solid-js": "^1.9.8" + } + }, + "node_modules/@kobalte/utils": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@kobalte/utils/-/utils-0.9.2.tgz", + "integrity": "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow==", + "license": "MIT", + "dependencies": { + "@solid-primitives/event-listener": "^2.2.14", + "@solid-primitives/keyed": "^1.2.0", + "@solid-primitives/map": "^0.4.7", + "@solid-primitives/media": "^2.2.4", + "@solid-primitives/props": "^3.1.8", + "@solid-primitives/refs": "^1.0.5", + "@solid-primitives/utils": "^6.2.1" + }, + "peerDependencies": { + "solid-js": "^1.8.8" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", @@ -1619,24 +1816,6 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1673,12 +1852,12 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { @@ -1707,10 +1886,32 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "cpu": [ "arm64" ], @@ -1724,9 +1925,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "cpu": [ "arm64" ], @@ -1740,9 +1941,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "cpu": [ "x64" ], @@ -1756,9 +1957,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "cpu": [ "x64" ], @@ -1772,9 +1973,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "cpu": [ "arm" ], @@ -1788,12 +1989,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1804,12 +2008,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1820,12 +2027,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1836,12 +2046,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1852,12 +2065,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1868,12 +2084,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1884,9 +2103,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "cpu": [ "arm64" ], @@ -1899,49 +2118,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "cpu": [ "arm64" ], @@ -1955,9 +2135,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "cpu": [ "x64" ], @@ -1977,39 +2157,39 @@ "license": "MIT" }, "node_modules/@sentry/browser": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", - "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz", + "integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", + "@sentry/browser-utils": "10.75.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/feedback": "10.73.0", - "@sentry/replay": "10.73.0", - "@sentry/replay-canvas": "10.73.0" + "@sentry/core": "10.75.0", + "@sentry/feedback": "10.75.0", + "@sentry/replay": "10.75.0", + "@sentry/replay-canvas": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/browser-utils": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", - "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz", + "integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/cli": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", - "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.8.0.tgz", + "integrity": "sha512-P/Zq0QEXnBbNX8oxd1maHKD06oAH5fxHsxt8QjEzi2J14w4hvwUi6FcMDf7eyuUkdQs8+OvOKeq/9pLuZxVVxQ==", "dev": true, "hasInstallScript": true, "license": "FSL-1.1-MIT", @@ -2026,20 +2206,20 @@ "node": ">= 18" }, "optionalDependencies": { - "@sentry/cli-darwin": "3.7.0", - "@sentry/cli-linux-arm": "3.7.0", - "@sentry/cli-linux-arm64": "3.7.0", - "@sentry/cli-linux-i686": "3.7.0", - "@sentry/cli-linux-x64": "3.7.0", - "@sentry/cli-win32-arm64": "3.7.0", - "@sentry/cli-win32-i686": "3.7.0", - "@sentry/cli-win32-x64": "3.7.0" + "@sentry/cli-darwin": "3.8.0", + "@sentry/cli-linux-arm": "3.8.0", + "@sentry/cli-linux-arm64": "3.8.0", + "@sentry/cli-linux-i686": "3.8.0", + "@sentry/cli-linux-x64": "3.8.0", + "@sentry/cli-win32-arm64": "3.8.0", + "@sentry/cli-win32-i686": "3.8.0", + "@sentry/cli-win32-x64": "3.8.0" } }, "node_modules/@sentry/cli-darwin": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", - "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.8.0.tgz", + "integrity": "sha512-i+qTGaHqL6LaIfMYuEohExJyzZwvRRsiQm+vrol/WfFGvqogeijXAU2wXn3xBglszUK/ia1xWH4KxcnqNLOpdQ==", "dev": true, "license": "FSL-1.1-MIT", "optional": true, @@ -2051,9 +2231,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", - "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.8.0.tgz", + "integrity": "sha512-wcpKNI22usaf7ZIHLaWofHVN5wOlmTdVjnQO5pYqDZNHRvhbO7HwZfLghR/p7aLuhETtePIFhcgjWlyU4uvWZQ==", "cpu": [ "arm" ], @@ -2070,9 +2250,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", - "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.8.0.tgz", + "integrity": "sha512-CS43j4vbLJBoB4c5MRZS7/nCf62yCnPIMQj7N+ZC50ze6EM8efEy4jq1ZZjz4G9u6AKuo6sntuaOlOGdZL5l6g==", "cpu": [ "arm64" ], @@ -2089,9 +2269,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", - "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.8.0.tgz", + "integrity": "sha512-ckQ5XeY+D5ISyFKrGnmYt5xarqQ1VHPMBJQnwnG0Hb1V116efAIusK5NqoHMP0o5Bu17BRlaxYA1l81HmvKqFQ==", "cpu": [ "x86", "ia32" @@ -2109,9 +2289,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", - "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.8.0.tgz", + "integrity": "sha512-jMXbyLIuGFW1uuFSByy54edkHPIaiCVyymKW0ia+lgA6nfFEH4Y1Z+bxXyK6BBr8TD6/P5ROqiPivg0oFEDciQ==", "cpu": [ "x64" ], @@ -2128,9 +2308,9 @@ } }, "node_modules/@sentry/cli-win32-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", - "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.8.0.tgz", + "integrity": "sha512-AtfvaIZ9hK/l2Q2It6PLTtLlQTRe7179kZVi++qd98iE7Ix4ysdgshhozW5uUu4gI9kauJC/fhe21p9lr0uwlA==", "cpu": [ "arm64" ], @@ -2145,9 +2325,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", - "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.8.0.tgz", + "integrity": "sha512-lIbGRYAM4DZPzwLiqOsT/vwhOr0o1qqx46buxdMnH05KZAYzMtGDbK0X9iGdhWQLOOp1/2vuUNVgMgCzSDyHXA==", "cpu": [ "x86", "ia32" @@ -2163,9 +2343,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", - "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.8.0.tgz", + "integrity": "sha512-PJk9uYZsVnUcf7BfeJZcmgRIymRWojIVYCx3704VlOr65JFP5KmeKBVgHzfwPqLfjZaeerL81NO0fG3recDjqA==", "cpu": [ "x64" ], @@ -2180,9 +2360,9 @@ } }, "node_modules/@sentry/cli/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "dev": true, "license": "MIT", "engines": { @@ -2199,9 +2379,9 @@ } }, "node_modules/@sentry/core": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", - "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz", + "integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0" @@ -2211,26 +2391,26 @@ } }, "node_modules/@sentry/feedback": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", - "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz", + "integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", - "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz", + "integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.73.0", + "@sentry/browser": "10.75.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" @@ -2240,26 +2420,26 @@ } }, "node_modules/@sentry/replay": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", - "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz", + "integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", - "@sentry/core": "10.73.0" + "@sentry/browser-utils": "10.75.0", + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/replay-canvas": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", - "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz", + "integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0", - "@sentry/replay": "10.73.0" + "@sentry/core": "10.75.0", + "@sentry/replay": "10.75.0" }, "engines": { "node": ">=18" @@ -2294,11 +2474,164 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@solid-primitives/event-listener": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@solid-primitives/event-listener/-/event-listener-2.4.6.tgz", + "integrity": "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/keyed": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@solid-primitives/keyed/-/keyed-1.5.3.tgz", + "integrity": "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/map": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@solid-primitives/map/-/map-0.4.13.tgz", + "integrity": "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==", + "license": "MIT", + "dependencies": { + "@solid-primitives/trigger": "^1.1.0" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/media": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@solid-primitives/media/-/media-2.3.6.tgz", + "integrity": "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw==", + "license": "MIT", + "dependencies": { + "@solid-primitives/event-listener": "^2.4.6", + "@solid-primitives/rootless": "^1.5.4", + "@solid-primitives/static-store": "^0.1.4", + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/props": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@solid-primitives/props/-/props-3.2.4.tgz", + "integrity": "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/refs": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@solid-primitives/refs/-/refs-1.1.4.tgz", + "integrity": "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/resize-observer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@solid-primitives/resize-observer/-/resize-observer-2.2.0.tgz", + "integrity": "sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==", + "license": "MIT", + "dependencies": { + "@solid-primitives/event-listener": "^2.4.6", + "@solid-primitives/rootless": "^1.5.4", + "@solid-primitives/static-store": "^0.1.4", + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/rootless": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@solid-primitives/rootless/-/rootless-1.5.4.tgz", + "integrity": "sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/static-store": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@solid-primitives/static-store/-/static-store-0.1.4.tgz", + "integrity": "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/storage": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-1.3.11.tgz", + "integrity": "sha512-PpQWR3TaTxHIJFbI9ZssYTM4Aa67g1vJIgps4TPhcXzHqqomrPAIveFC2FG7SDQoi9YQia8FVBjigELziJpfIg==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.2.0" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/transition-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@solid-primitives/transition-group/-/transition-group-1.1.2.tgz", + "integrity": "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/trigger": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@solid-primitives/trigger/-/trigger-1.2.4.tgz", + "integrity": "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.4.1" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solid-primitives/utils": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.4.1.tgz", + "integrity": "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -2307,50 +2640,59 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -2365,9 +2707,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -2382,9 +2724,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -2399,9 +2741,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -2416,9 +2758,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -2433,13 +2775,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2450,13 +2795,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2467,13 +2815,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2484,13 +2835,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2501,9 +2855,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2530,6 +2884,27 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -2576,9 +2951,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -2593,9 +2968,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -2610,27 +2985,43 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tanstack/query-broadcast-client-experimental": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz", - "integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==", + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2", + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-broadcast-client-experimental": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.103.1.tgz", + "integrity": "sha512-PJPupLlVjL3YMd7A9HRcEgYnH4dptAKrH2egsGFk4PCx/sPYaVhnEoHXCGwxQMl2qKnxvQWNBlmCz04BGIwwaw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1", "broadcast-channel": "^7.0.0" }, "funding": { @@ -2639,9 +3030,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz", + "integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==", "license": "MIT", "funding": { "type": "github", @@ -2649,22 +3040,35 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz", - "integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==", + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.103.1.tgz", + "integrity": "sha512-xMOcnULRPEjww0A/0RnweNmcZ5hesLjTekqf8SHxf+BJH729qz6sUu5TsOWFo7J4fu3OR50MeuJUsM3BN78Kfw==", "license": "MIT", + "dependencies": { + "@kobalte/core": "^0.13.4", + "@solid-primitives/keyed": "^1.2.2", + "@solid-primitives/resize-observer": "^2.0.26", + "@solid-primitives/storage": "^1.3.11", + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/query-core": "5.103.1", + "clsx": "^2.1.1", + "goober": "^2.1.16", + "solid-js": "^1.9.7", + "solid-transition-group": "^0.2.3", + "superjson": "^2.2.2" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz", + "integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.103.1" }, "funding": { "type": "github", @@ -2675,26 +3079,27 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz", - "integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==", + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.103.1.tgz", + "integrity": "sha512-l6OzwPbDCnuim8w9McFs3dC/rdXTMZBIBFMVrXzNuhUEzWheldubOu+nKRDtPf+1nLRuwavb3We+nNJq/0vV6w==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.101.2" + "@tanstack/query-devtools": "5.103.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query": "^5.103.1", + "@types/react": "^18 || ^19", "react": "^18 || ^19" } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -2736,9 +3141,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" @@ -2763,9 +3168,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "license": "MIT", "engines": { "node": ">=12", @@ -2775,16 +3180,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2831,9 +3226,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2853,22 +3248,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-transition-group": { @@ -2881,17 +3276,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2904,7 +3299,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2920,16 +3315,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -2945,14 +3340,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -2967,14 +3362,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2985,9 +3380,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -3002,15 +3397,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3027,9 +3422,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -3041,16 +3436,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3069,16 +3464,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3093,13 +3488,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3111,9 +3506,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3125,6 +3520,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -3133,6 +3529,9 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, @@ -3314,7 +3713,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3327,22 +3726,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3578,6 +3961,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3618,39 +4015,6 @@ "node": ">=18" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -3725,13 +4089,13 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", - "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz", + "integrity": "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.1" + "@conventional-changelog/template": "^1.4.0" }, "engines": { "node": ">=22" @@ -3779,6 +4143,18 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/copy-anything": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.1.tgz", + "integrity": "sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cosmiconfig": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", @@ -3969,9 +4345,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", "dev": true, "license": "MIT", "dependencies": { @@ -4005,19 +4381,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4044,14 +4407,16 @@ "license": "MIT" }, "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", "dev": true, "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types", + "tests/browser-compat" ] }, "node_modules/escalade": { @@ -4077,9 +4442,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -4089,9 +4454,9 @@ "@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/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4106,14 +4471,14 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "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", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4172,9 +4537,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4311,13 +4676,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4332,7 +4690,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4381,7 +4739,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -4421,16 +4779,13 @@ } }, "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==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/fill-range": { @@ -4469,23 +4824,21 @@ } }, "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==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -4585,9 +4938,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { @@ -4597,6 +4950,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4614,6 +4976,19 @@ "node": ">=8" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -4658,6 +5033,13 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4788,22 +5170,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4993,13 +5359,6 @@ "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", @@ -5010,7 +5369,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5034,19 +5393,19 @@ } }, "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==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/ky": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", - "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.1.0.tgz", + "integrity": "sha512-nIKwelw+7qVqKOlX4Eht6GXfEXlDHcdIjyLWV3s119kYt5s+70ayTnYS+0fRNmtgGRRAfqdeWQbh8YizqkS0ng==", "license": "MIT", "engines": { "node": ">=22" @@ -5073,6 +5432,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -5105,6 +5465,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5125,6 +5486,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5145,6 +5507,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5165,6 +5528,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5185,6 +5549,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5205,6 +5570,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5225,6 +5591,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5245,6 +5612,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5265,6 +5633,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5285,6 +5654,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5305,6 +5675,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5325,16 +5696,15 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.5.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.5.1.tgz", + "integrity": "sha512-7EDuco1xnBMeVpvAbeMq1U5KXwJGLmY8q6l+Ye78r36C4mPc+Vg1Z2SK8gHyRTyvPro90A0q5/FAZ+Au23d6QQ==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.7", "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" + "tinyexec": "^1.3.1" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -5349,23 +5719,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", - "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5382,92 +5735,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5491,9 +5758,9 @@ } }, "node_modules/lucide-react": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", - "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5587,19 +5854,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5698,22 +5952,6 @@ "node": ">=12.20.0" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5899,9 +6137,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -5943,9 +6181,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -5962,7 +6200,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5992,9 +6230,9 @@ } }, "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", "bin": { @@ -6071,6 +6309,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6092,30 +6350,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.3.0" } }, "node_modules/react-hook-form": { - "version": "7.81.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", - "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", + "version": "7.88.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", + "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6138,17 +6396,18 @@ } }, "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", "license": "MIT" }, "node_modules/react-router": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", - "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", "license": "MIT", "dependencies": { + "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "engines": { @@ -6206,11 +6465,17 @@ "node": ">=8" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6247,23 +6512,6 @@ "node": ">=8" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6274,20 +6522,13 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6297,21 +6538,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, "node_modules/run-parallel": { @@ -6351,9 +6592,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -6369,6 +6610,27 @@ "node": ">=10" } }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6399,47 +6661,56 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, + "node_modules/solid-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz", + "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, + "node_modules/solid-presence": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/solid-presence/-/solid-presence-0.2.0.tgz", + "integrity": "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@corvu/utils": "~0.4.2" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "solid-js": "^1.8" + } + }, + "node_modules/solid-prevent-scroll": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/solid-prevent-scroll/-/solid-prevent-scroll-0.1.11.tgz", + "integrity": "sha512-2PComVCDHaQN/5t7ogEqUYeHasritZKXZ8Sb/VLkl0Web8o9YhEwOo8LSujJEahvMlYCNyKt0zRJeHldOAeWZg==", + "license": "MIT", + "dependencies": { + "@corvu/utils": "~0.4.2" + }, + "peerDependencies": { + "solid-js": "^1.8" + } + }, + "node_modules/solid-transition-group": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/solid-transition-group/-/solid-transition-group-0.2.3.tgz", + "integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==", + "license": "MIT", + "dependencies": { + "@solid-primitives/refs": "^1.0.5", + "@solid-primitives/transition-group": "^1.0.2" + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.6.0" + }, + "peerDependencies": { + "solid-js": "^1.6.12" } }, "node_modules/source-map": { @@ -6484,23 +6755,6 @@ "node": ">=0.6.19" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -6548,6 +6802,18 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6581,9 +6847,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -6591,9 +6857,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -6619,9 +6885,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -6729,8 +6995,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -6760,16 +7025,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6851,15 +7116,15 @@ } }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -6876,7 +7141,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6941,6 +7206,267 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", @@ -7122,37 +7648,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -7263,9 +7758,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index b1fc3700..01a3f43b 100644 --- a/package.json +++ b/package.json @@ -50,63 +50,63 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", - "@fontsource/montserrat": "^5.2.8", + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/montserrat": "^5.3.0", "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/interaction": "^6.1.21", "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@generouted/react-router": "^1.20.2", - "@hookform/resolvers": "^5.4.0", - "@mui/icons-material": "^9.2.0", - "@mui/material": "^9.2.0", - "@sentry/react": "10.73.0", - "@tanstack/query-broadcast-client-experimental": "5.101.2", - "@tanstack/react-query": "5.101.2", - "@tanstack/react-query-devtools": "5.101.2", - "@testing-library/dom": "^10.4.1", + "@hookform/resolvers": "^5.9.1", + "@mui/icons-material": "9.2.0", + "@mui/material": "9.2.0", + "@sentry/react": "10.75.0", + "@tanstack/query-broadcast-client-experimental": "5.103.1", + "@tanstack/react-query": "5.103.1", + "@tanstack/react-query-devtools": "5.103.1", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "clsx": "^2.1.1", - "ky": "^2.0.2", - "lucide-react": "^1.24.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-hook-form": "^7.81.0", + "ky": "^2.1.0", + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-hook-form": "^7.88.0", "react-icons": "^5.7.0", - "react-router": "^8.2.0", + "react-router": "^8.4.0", "react-toastify": "^11.1.0", - "tailwind-merge": "^3.6.0", - "zod": "^4.4.3" + "tailwind-merge": "^3.7.0", + "zod": "^4.6.5" }, "devDependencies": { - "@commitlint/cli": "^21.2.1", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", - "@sentry/cli": "3.7.0", - "@tailwindcss/vite": "^4.3.2", - "@types/node": "^24.13.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "@vitest/coverage-v8": "^4.1.10", - "eslint": "^10.7.0", + "@sentry/cli": "3.8.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.13.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", "husky": "^9.1.7", "jsdom": "^29.1.1", - "lint-staged": "^17.0.8", - "prettier": "^3.9.5", - "tailwindcss": "^4.3.2", + "lint-staged": "^17.5.1", + "prettier": "^3.9.8", + "tailwindcss": "^4.3.3", "typescript": "~6.0.3", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.4", - "vitest": "^4.1.10" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=22.22.1" diff --git a/src/api/api-error.ts b/src/api/api-error.ts index d9dabc83..3fdabbb3 100644 --- a/src/api/api-error.ts +++ b/src/api/api-error.ts @@ -10,11 +10,21 @@ export class ApiError extends Error { } } +export const REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE = + "Your role can't request uplifts on this work order."; + export function mapHttpStatusToMessage(status: number, data?: unknown): string { if (status === 401) { return "You are not authorized to access this page."; } if (status === 403) { + if (data && typeof data === "object") { + const record = data as Record; + const message = record.message ?? record.Message ?? record.error; + if (message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) { + return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE; + } + } return "You do not have permission to perform this action."; } if (status === 404) { diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 9048bad4..a413552a 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -33,6 +33,9 @@ export const API_PATHS = { pocPatch: (id: string | number) => `workorders/${id}/poc`, boardDetail: (id: string | number) => `workorders/${id}/detail`, completionTemplates: "workorders/completion-templates", + completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`, + completionTemplateLinkedWorkOrders: (id: string | number) => + `workorders/completion-templates/${id}/linked-work-orders`, comments: (id: string | number) => `workorders/${id}/comments`, commentItem: (workOrderId: string | number, commentId: string | number) => `workorders/${workOrderId}/comments/${commentId}`, @@ -86,6 +89,14 @@ export const API_PATHS = { getList: "User", create: "team-members", detail: "team-members", + invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`, + myPermissions: "team-members/me/permissions", + }, + teamMemberInvite: { + resolve: "team-member-invites/resolve", + sendCode: "team-member-invites/send-code", + verifyCode: "team-member-invites/verify-code", + complete: "team-member-invites/complete", }, contact: { getList: "Contact/GetContactList", diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index a5826b19..bffc9e3d 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; export default function AdminShellLayout() { @@ -40,6 +41,7 @@ export default function AdminShellLayout() { sx={{ width: `calc(100% - ${sidebarWidth}px)` }} > + + + {label} + + + {value || "—"} + + + ); +} + +export function AccountSummaryCard({ user }: { user: AuthUser }) { + return ( +
+
+ + +
+ + + Contact your Admin to change your name, role, or email. + +
+ ); +} diff --git a/src/app/(protected)/account/_components/change-password-section.tsx b/src/app/(protected)/account/_components/change-password-section.tsx new file mode 100644 index 00000000..c464f6ed --- /dev/null +++ b/src/app/(protected)/account/_components/change-password-section.tsx @@ -0,0 +1,132 @@ +import { useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box } from "@mui/material"; +import { KeyRound } from "lucide-react"; + +import { PasswordChecklist } from "@/components/auth/password-checklist"; +import { Button } from "@/components/ui/button"; +import { FormField } from "@/components/ui/form-field"; +import { PasswordField } from "@/components/ui/password-field"; +import { Text } from "@/components/ui/text"; +import { + CURRENT_PASSWORD_INCORRECT_MESSAGE, + changePasswordSchema, + type ChangePasswordFormValues, +} from "@/domain/auth/schemas/change-password-schema"; +import { useChangePassword } from "@/domain/auth/use-cases/use-change-password"; + +const EMPTY_VALUES: ChangePasswordFormValues = { + currentPassword: "", + newPassword: "", + confirmPassword: "", +}; + +export function ChangePasswordSection() { + const changePassword = useChangePassword(); + const { + register, + handleSubmit, + control, + reset, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: EMPTY_VALUES, + mode: "onTouched", + }); + + const values = useWatch({ control }); + const newPassword = values.newPassword ?? ""; + const canSubmit = changePasswordSchema.safeParse(values).success; + const serverError = changePassword.error?.message; + const showServerError = + Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE; + + const onSubmit = (form: ChangePasswordFormValues) => { + changePassword.mutate( + { + Currentpassword: form.currentPassword, + Newpassword: form.newPassword, + Confirmpassword: form.confirmPassword, + }, + { + onSuccess: () => reset(EMPTY_VALUES), + onError: (error) => { + if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) { + setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE }); + } + }, + }, + ); + }; + + return ( + +
+
+ + + + + + + + + + + + + + + {showServerError && {serverError}} + +
+ +
+
+ ); +} diff --git a/src/app/(protected)/account/index.tsx b/src/app/(protected)/account/index.tsx new file mode 100644 index 00000000..1a645a09 --- /dev/null +++ b/src/app/(protected)/account/index.tsx @@ -0,0 +1,24 @@ +import { Box } from "@mui/material"; + +import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card"; +import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section"; +import { PageHeader } from "@/components/ui/page-header"; +import { useAuthContext } from "@/providers/auth-context"; + +export default function MyAccountPage() { + const { user } = useAuthContext(); + + return ( + + + + {user != null && } + + + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx new file mode 100644 index 00000000..4129b919 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export function CompletionTemplateAddLink({ + onClick, + children, +}: { + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx b/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx new file mode 100644 index 00000000..63aeb2b5 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx @@ -0,0 +1,42 @@ +import { Text } from "@/components/ui/text"; + +function initials(name: string): string { + return name + .trim() + .split(/\s+/) + .map((part) => part[0] ?? "") + .join("") + .slice(0, 2) + .toUpperCase(); +} + +export function CompletionTemplateCreatedBy({ name }: { name: string | null }) { + if (name === null) { + return ( + + — + + ); + } + return ( + + + {initials(name)} + + + {name} + + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx b/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx new file mode 100644 index 00000000..b87b4978 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { descriptionLines } from "@/domain/completion-templates/utils/description-lines"; +import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date"; +import { CompletionTemplateCreatedBy } from "./completion-template-created-by"; +import { + AutoPopulatedFields, + FieldLabel, + SectionTitle, + StandardSafetyBullets, +} from "./completion-template-sections"; + +function ReadOnlyField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function ProcedureList({ template }: { template: CompletionTemplate }) { + if (template.procedures.length === 0) { + return ( + + {COMPLETION_TEMPLATE_COPY.noProcedureDetail} + + ); + } + return ( +
    + {template.procedures.map((procedure, index) => ( +
  1. + + {procedure.name || `Procedure ${index + 1}`} + + {descriptionLines(procedure.description).map((line, lineIndex) => ( + + {line} + + ))} +
  2. + ))} +
+ ); +} + +/** Everything a template holds, read-only. */ +export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) { + return ( +
+ Completion Document + + + {template.name || "—"} + + + +
+ Safety Notes + +
+
+ Procedures + +
+
+ + + {formatTemplateDate(template.lastUpdatedAt)} + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx b/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx new file mode 100644 index 00000000..21b7dfe6 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx @@ -0,0 +1,105 @@ +import { FileText, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import { Text } from "@/components/ui/text"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplatePermissions } from "./use-completion-template-permissions"; +import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog"; + +export function CompletionTemplateDialogHeader({ + title, + subtitle, + onClose, +}: { + title: string; + subtitle: string; + onClose: () => void; +}) { + return ( +
+
+ +
+ + {title} + + + {subtitle} + +
+
+ +
+ ); +} + +type FooterProps = { + mode: CompletionTemplateDialogMode; + permissions: CompletionTemplatePermissions; + isSaving: boolean; + onCancel: () => void; + onSubmit: () => void; + onEdit: () => void; + onDelete: () => void; +}; + +export function CompletionTemplateDialogFooter({ + mode, + permissions, + isSaving, + onCancel, + onSubmit, + onEdit, + onDelete, +}: FooterProps) { + const footerStyle = { + padding: "12px 20px", + borderTop: "1px solid var(--color-border)", + background: "var(--color-bg-surface)", + flexShrink: 0, + }; + if (mode !== "view") { + return ( +
+ + +
+ ); + } + return ( +
+ {permissions.canDelete && ( + + )} + {permissions.canEdit && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts b/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts new file mode 100644 index 00000000..5455dd7e --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts @@ -0,0 +1,21 @@ +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date"; +import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog"; + +export function dialogHeading( + template: CompletionTemplate | null, + mode: CompletionTemplateDialogMode, +): { title: string; subtitle: string } { + if (template === null) { + return { + title: "New Completion Document Template", + subtitle: "Completion Document Template", + }; + } + if (mode === "edit") return { title: template.name, subtitle: "Editing template" }; + const createdBy = template.createdByName ?? "—"; + return { + title: template.name, + subtitle: `Created by ${createdBy} · Updated ${formatTemplateDate(template.lastUpdatedAt)}`, + }; +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx b/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx new file mode 100644 index 00000000..baaab431 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx @@ -0,0 +1,102 @@ +import { Dialog, DialogTitle } from "@mui/material"; +import { useState } from "react"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { CompletionTemplateDetail } from "./completion-template-detail"; +import { + CompletionTemplateDialogFooter, + CompletionTemplateDialogHeader, +} from "./completion-template-dialog-chrome"; +import { dialogHeading } from "./completion-template-dialog-heading"; +import { CompletionTemplateForm } from "./completion-template-form"; +import { CompletionTemplatePreview } from "./completion-template-preview"; +import { DeleteCompletionTemplateDialog } from "./delete-completion-template-dialog"; +import { + useCompletionTemplateDialog, + type CompletionTemplateDialogMode, +} from "./use-completion-template-dialog"; +import type { CompletionTemplatePermissions } from "./use-completion-template-permissions"; + +type CompletionTemplateDialogProps = { + /** `null` adds a new template. */ + template: CompletionTemplate | null; + initialMode: CompletionTemplateDialogMode; + permissions: CompletionTemplatePermissions; + onClose: () => void; + onSaved: (template: CompletionTemplate) => void; +}; + +/** View, add or edit one template beside its document preview. */ +export function CompletionTemplateDialog({ + template, + initialMode, + permissions, + onClose, + onSaved, +}: CompletionTemplateDialogProps) { + const dialog = useCompletionTemplateDialog({ template, initialMode, onClose, onSaved }); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const heading = dialogHeading(template, dialog.mode); + + return ( + + + {heading.title} + + +
+ +
+
+ {dialog.isEditing ? ( + + ) : ( + template !== null && + )} +
+
+
+ void dialog.submit()} + onEdit={dialog.startEditing} + onDelete={() => setConfirmingDelete(true)} + /> + {template !== null && permissions.canDelete && ( + setConfirmingDelete(false)} + onDeleted={onClose} + /> + )} +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-form.tsx b/src/app/(protected)/completion-docs/_components/completion-template-form.tsx new file mode 100644 index 00000000..07e77b68 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-form.tsx @@ -0,0 +1,64 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { FormField } from "@/components/ui/form-field"; +import { TextField } from "@/components/ui/text-field"; +import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; +import { CompletionTemplateProceduresEditor } from "./completion-template-procedures-editor"; +import { CompletionTemplateSafetyNoteField } from "./completion-template-safety-note-field"; +import { + AutoPopulatedFields, + FieldLabel, + SectionTitle, + StandardSafetyBullets, +} from "./completion-template-sections"; + +type CompletionTemplateFormProps = { + form: UseFormReturn; +}; + +function TitleField({ form }: CompletionTemplateFormProps) { + return ( + ( + + + + )} + /> + ); +} + +/** The editable template: title, fixed references, the optional note and procedures. */ +export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) { + return ( +
+ Completion Document + + +
+ Safety Notes + + +
+
+ Procedures + +
+
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx new file mode 100644 index 00000000..62f5a731 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx @@ -0,0 +1,139 @@ +import { FileText } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Text } from "@/components/ui/text"; +import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame"; +import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header"; +import { CompDocProcedureHeading } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading"; +import { CompDocSafetyBullets } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets"; +import { + AUTO_POPULATED_FIELDS, + COMPLETION_TEMPLATE_COPY, +} from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template"; +import { descriptionLines } from "@/domain/completion-templates/utils/description-lines"; +import { + pageIndicatorLabel, + type PagePosition, +} from "@/domain/work-orders/utils/completion-doc-pagination"; + +export interface CompletionTemplatePreviewValues { + name: string; + extraSafetyNote: string | null; + procedures: CompletionTemplateProcedure[]; +} + +type CompletionTemplatePreviewProps = { + values: CompletionTemplatePreviewValues; + isEditing: boolean; +}; + +function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) { + return ( +
+ + + {isEditing ? "Live preview" : "Document preview"} + {pageIndicatorLabel(page)} + +
+ ); +} + +function PreviewTitle({ name }: { name: string }) { + const title = name.trim(); + return ( + <> + + {title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument} + + + {AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix} + + + ); +} + +/** One block per procedure (each kept whole on a page), or the empty-state line. */ +function previewSections(procedures: CompletionTemplateProcedure[]): ReactNode[] { + if (procedures.length === 0) { + return [ + + {COMPLETION_TEMPLATE_COPY.noProcedurePreview} + , + ]; + } + return procedures.map((procedure, index) => ( +
+ + {procedure.name.trim() || `Procedure ${index + 1}`} + + {descriptionLines(procedure.description).map((line, lineIndex) => ( + + {line} + + ))} +
+ )); +} + +/** The template rendered on the same page, header and safety bullets as a work order's document. */ +export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) { + const [page, setPage] = useState({ current: 1, total: 1 }); + const note = values.extraSafetyNote?.trim() ?? ""; + return ( +
+ +
+ + + + + + } + sections={previewSections(values.procedures)} + /> +
+
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx new file mode 100644 index 00000000..d35a2ec4 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx @@ -0,0 +1,103 @@ +import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; +import { Text } from "@/components/ui/text"; +import { TextField } from "@/components/ui/text-field"; +import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; +import { CompletionTemplateAddLink } from "./completion-template-add-link"; +import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; + +type ProceduresEditorProps = { + form: UseFormReturn; +}; + +const DESCRIPTION_PLACEHOLDER = + "Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well."; + +function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) { + return ( +
+ ( + + )} + /> + ( + + )} + /> +
+ ); +} + +/** Procedures print in the order they are listed here. */ +export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) { + const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" }); + + return ( +
+ {fields.map((field, index) => ( +
+
+ + Procedure {index + 1} + + remove(index)} + /> +
+ +
+ ))} + append({ name: "", description: "" })}> + + Add procedure + +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx new file mode 100644 index 00000000..f1d0b1b5 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx @@ -0,0 +1,22 @@ +import { X } from "lucide-react"; + +export function CompletionTemplateRemoveButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx new file mode 100644 index 00000000..fad8cac5 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx @@ -0,0 +1,55 @@ +import { Controller, useWatch, type UseFormReturn } from "react-hook-form"; +import { TextField } from "@/components/ui/text-field"; +import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; +import { CompletionTemplateAddLink } from "./completion-template-add-link"; +import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; + +type SafetyNoteFieldProps = { + form: UseFormReturn; +}; + +/** At most one template-specific note, printed after the standard bullets. */ +export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) { + const note = useWatch({ control: form.control, name: "extraSafetyNote" }); + const setNote = (value: string | null) => + form.setValue("extraSafetyNote", value, { shouldDirty: true }); + + if (note === null) { + return ( + setNote("")}> + + Add safety note + + ); + } + return ( +
+ ( + + )} + /> +
+ setNote(null)} /> +
+
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx new file mode 100644 index 00000000..6dd48810 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; +import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants"; +import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; + +export function SectionTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) { + return ( +
+ + {children} + + {hint !== undefined && ( + + {hint} + + )} +
+ ); +} + +export function AutoPopulatedFields() { + return ( +
+ Auto-populated fields +
    + {AUTO_POPULATED_FIELDS.map((field) => ( +
  • + {field} +
  • + ))} +
+
+ ); +} + +/** The standard bullets every generated document prints; never editable per template. */ +export function StandardSafetyBullets({ extra }: { extra?: string | null }) { + return ( +
    + {SAFETY_BULLETS.map((bullet) => ( +
  • + {bullet} +
  • + ))} + {Boolean(extra) && ( +
  • {extra}
  • + )} +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx new file mode 100644 index 00000000..5756ad64 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx @@ -0,0 +1,162 @@ +import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material"; +import { Eye, FileText, Pencil, Search } from "lucide-react"; +import type { MouseEvent } from "react"; +import { TableLoadingRow } from "@/components/ui/table-state-rows"; +import { Text } from "@/components/ui/text"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date"; +import { CompletionTemplateCreatedBy } from "./completion-template-created-by"; + +const HEAD_CELL_SX = { + fontSize: 10.5, + fontWeight: 700, + letterSpacing: "0.06em", + textTransform: "uppercase", + background: "var(--color-bg-muted)", + padding: "11px 14px", + whiteSpace: "nowrap", +} as const; + +const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const; + +type CompletionTemplatesTableProps = { + templates: CompletionTemplate[]; + isLoading: boolean; + /** The load failed: the page shows the error, so there is no search result to explain. */ + hasError: boolean; + canEdit: boolean; + onView: (template: CompletionTemplate) => void; + onEdit: (template: CompletionTemplate) => void; +}; + +function RowAction({ + label, + icon: Icon, + onClick, +}: { + label: string; + icon: typeof Eye; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyRow() { + return ( + + +
+ + + {COMPLETION_TEMPLATE_COPY.emptySearchTitle} + + + {COMPLETION_TEMPLATE_COPY.emptySearchHint} + +
+
+
+ ); +} + +function TemplateRow({ + template, + canEdit, + onView, + onEdit, +}: Omit & { + template: CompletionTemplate; +}) { + return ( + onView(template)} sx={{ cursor: "pointer", height: 52 }}> + + + + + + {template.name} + + + + {formatTemplateDate(template.lastUpdatedAt)} + + + + + + + onView(template)} /> + {canEdit && onEdit(template)} />} + + + + ); +} + +export function CompletionTemplatesTable({ + templates, + isLoading, + hasError, + canEdit, + onView, + onEdit, +}: CompletionTemplatesTableProps) { + return ( + + + + Document Title + Last Updated + Created By + + Actions + + + + + {isLoading && } + {!isLoading && !hasError && templates.length === 0 && } + {templates.map((template) => ( + + ))} + +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx new file mode 100644 index 00000000..6ec9a5e4 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx @@ -0,0 +1,98 @@ +import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { useNavigate } from "react-router"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { Text } from "@/components/ui/text"; +import type { + CompletionTemplate, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; +import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations"; +import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +type DeleteCompletionTemplateDialogProps = { + template: CompletionTemplate; + open: boolean; + onClose: () => void; + onDeleted: () => void; +}; + +function deleteMessage(name: string, linked: LinkedWorkOrders): string { + if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`; + const plural = linked.count === 1 ? "" : "s"; + return ( + `This will permanently remove "${name}". It's still linked to ${linked.count} open work ` + + `order${plural} through its Service — deleting it won't change that Service, but new ` + + "documents generated for those work orders will fall back to a generic procedure until " + + "reassigned. This cannot be undone." + ); +} + +function LinkedWorkOrdersMessage({ + name, + linked, +}: { + name: string; + linked: ReturnType; +}) { + if (linked.isPending) { + return ( +
+ + + Checking linked work orders… + +
+ ); + } + if (linked.isError) { + return ( + + Couldn't check which work orders use this template. Close this and try again. + + ); + } + return {deleteMessage(name, linked.data)}; +} + +/** Deleting always asks first, and names the open work orders that still depend on the template. */ +export function DeleteCompletionTemplateDialog({ + template, + open, + onClose, + onDeleted, +}: DeleteCompletionTemplateDialogProps) { + const navigate = useNavigate(); + const linked = useLinkedWorkOrders(open ? template.id : null); + const remove = useDeleteCompletionTemplate(); + const linkedIds = linked.data?.workOrderIds ?? []; + + function confirmDelete() { + remove.mutate(template.id, { onSuccess: onDeleted }); + } + + return ( + + Delete this template? + + + + + + {linkedIds.length > 0 && ( + + )} + + + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts new file mode 100644 index 00000000..2c0e731c --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts @@ -0,0 +1,109 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import { + toCompletionTemplateFormValues, + toCompletionTemplateInput, +} from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { + completionTemplateFormSchema, + type CompletionTemplateFormValues, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { + isTitleRequiredError, + useCreateCompletionTemplate, + useUpdateCompletionTemplate, +} from "@/domain/completion-templates/use-cases/use-completion-template-mutations"; +import type { CompletionTemplatePreviewValues } from "./completion-template-preview"; + +export type CompletionTemplateDialogMode = "create" | "view" | "edit"; + +type UseCompletionTemplateDialogOptions = { + template: CompletionTemplate | null; + initialMode: CompletionTemplateDialogMode; + onClose: () => void; + onSaved: (template: CompletionTemplate) => void; +}; + +function usePreviewValues( + control: ReturnType>["control"], +): CompletionTemplatePreviewValues { + const watched = useWatch({ control }); + return { + name: watched.name ?? "", + extraSafetyNote: watched.extraSafetyNote ?? null, + procedures: (watched.procedures ?? []).map((procedure) => ({ + name: procedure?.name ?? "", + description: procedure?.description ?? "", + })), + }; +} + +export function useCompletionTemplateDialog({ + template, + initialMode, + onClose, + onSaved, +}: UseCompletionTemplateDialogOptions) { + const [mode, setMode] = useState( + template === null ? "create" : initialMode, + ); + const form = useForm({ + resolver: zodResolver(completionTemplateFormSchema), + defaultValues: toCompletionTemplateFormValues(template), + }); + const previewValues = usePreviewValues(form.control); + const create = useCreateCompletionTemplate(); + const update = useUpdateCompletionTemplate(); + + function showServerTitleError(error: Error) { + if (!isTitleRequiredError(error)) return; + form.setError( + "name", + { message: COMPLETION_TEMPLATE_COPY.titleRequired }, + { shouldFocus: true }, + ); + } + + const submit = form.handleSubmit((values) => { + const input = toCompletionTemplateInput(values); + if (template === null) { + create.mutate(input, { onSuccess: onClose, onError: showServerTitleError }); + return; + } + update.mutate( + { template, input }, + { + onSuccess: (saved) => { + form.reset(toCompletionTemplateFormValues(saved)); + setMode("view"); + onSaved(saved); + }, + onError: showServerTitleError, + }, + ); + }); + + /** Discards unsaved edits: back to the template as it was, or closed when creating. */ + function cancel() { + if (template === null) { + onClose(); + return; + } + form.reset(toCompletionTemplateFormValues(template)); + setMode("view"); + } + + return { + mode, + isEditing: mode !== "view", + form, + previewValues, + submit, + cancel, + startEditing: () => setMode("edit"), + isSaving: create.isPending || update.isPending, + }; +} diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts new file mode 100644 index 00000000..b835e35a --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts @@ -0,0 +1,17 @@ +import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions"; + +export interface CompletionTemplatePermissions { + canCreate: boolean; + canEdit: boolean; + canDelete: boolean; +} + +/** Template actions follow the signed-in user's effective permissions, never their role alone. */ +export function useCompletionTemplatePermissions(): CompletionTemplatePermissions { + const permissions = usePermissions(); + return { + canCreate: hasPermission(permissions, "createCompletionDocTemplates"), + canEdit: hasPermission(permissions, "editCompletionDocTemplates"), + canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"), + }; +} diff --git a/src/app/(protected)/completion-docs/index.tsx b/src/app/(protected)/completion-docs/index.tsx new file mode 100644 index 00000000..a17139c3 --- /dev/null +++ b/src/app/(protected)/completion-docs/index.tsx @@ -0,0 +1,99 @@ +import { Box } from "@mui/material"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { + COMPLETION_TEMPLATE_COPY, + templateCountLabel, +} from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { useCompletionTemplates } from "@/domain/completion-templates/use-cases/use-completion-templates"; +import { useDebounce } from "@/hooks/use-debounce"; +import { CompletionTemplateDialog } from "./_components/completion-template-dialog"; +import { CompletionTemplatesTable } from "./_components/completion-templates-table"; +import type { CompletionTemplateDialogMode } from "./_components/use-completion-template-dialog"; +import { useCompletionTemplatePermissions } from "./_components/use-completion-template-permissions"; + +type OpenDialog = { template: CompletionTemplate | null; mode: CompletionTemplateDialogMode }; + +export default function CompletionDocsPage() { + const permissions = useCompletionTemplatePermissions(); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 250); + const all = useCompletionTemplates(); + const filtered = useCompletionTemplates(debouncedSearch); + const [dialog, setDialog] = useState(null); + const templates = filtered.data ?? []; + + return ( + + + + {permissions.canCreate && ( + + )} + + } + /> + + {filtered.error !== null && ( + void filtered.refetch()} + /> + )} + +
+ setDialog({ template, mode: "view" })} + onEdit={(template) => setDialog({ template, mode: "edit" })} + /> +
+
+ + {templateCountLabel(templates.length)} + +
+ + {dialog !== null && ( + setDialog(null)} + onSaved={(template) => setDialog({ template, mode: "view" })} + /> + )} +
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx new file mode 100644 index 00000000..5329b2d2 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx @@ -0,0 +1,71 @@ +import { useNavigate } from "react-router"; +import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined"; +import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; +import PendingActionsIcon from "@mui/icons-material/PendingActions"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import { Box } from "@mui/material"; +import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + unassignedDrilldownSearch, + UPLIFTS_ROUTE, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +interface DashboardKpiCardsProps { + stats?: DashboardStats; + loading: boolean; + range: DashboardRangeKey; +} + +export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) { + const navigate = useNavigate(); + const scheduledTomorrow = stats?.scheduledTomorrow ?? 0; + const pendingUplifts = stats?.pendingUplifts ?? 0; + const avetaPending = stats?.avetaPending ?? 0; + const unassigned = stats?.unassigned ?? 0; + + return ( + + } + tone="primary" + value={stats?.scheduledTomorrow} + loading={loading} + active={scheduledTomorrow > 0} + onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} + /> + } + tone="info" + value={stats?.pendingUplifts} + loading={loading} + active={pendingUplifts > 0} + onSelect={() => navigate(UPLIFTS_ROUTE)} + /> + } + tone="warning" + value={stats?.avetaPending} + loading={loading} + active={avetaPending > 0} + onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} + /> + } + tone="warning" + value={stats?.unassigned} + loading={loading} + active={unassigned > 0} + onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx index 60795d8f..9dd4f6ff 100644 --- a/src/app/(protected)/dashboard/index.tsx +++ b/src/app/(protected)/dashboard/index.tsx @@ -1,11 +1,7 @@ -import { useNavigate } from "react-router"; -import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; -import PendingActionsIcon from "@mui/icons-material/PendingActions"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import { Box } from "@mui/material"; import { PageHeader } from "@/components/ui/page-header"; import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions"; -import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards"; import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card"; @@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights"; import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { - avetaPendingDrilldownSearch, - scheduledTomorrowDrilldownSearch, - UPLIFTS_ROUTE, - workOrderDrilldownUrl, -} from "@/domain/dashboard/utils/work-order-drilldown-links"; export default function DashboardPage() { - const navigate = useNavigate(); const scope = useDashboardScope(); const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope; @@ -62,35 +51,7 @@ export default function DashboardPage() { /> } /> - - } - tone="primary" - value={stats.data?.scheduledTomorrow} - loading={stats.isLoading} - active={(stats.data?.scheduledTomorrow ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} - /> - } - tone="info" - value={stats.data?.pendingUplifts} - loading={stats.isLoading} - active={(stats.data?.pendingUplifts ?? 0) > 0} - onSelect={() => navigate(UPLIFTS_ROUTE)} - /> - } - tone="warning" - value={stats.data?.avetaPending} - loading={stats.isLoading} - active={(stats.data?.avetaPending ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} - /> - + void; +} + +/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */ +export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) { + const createLocation = useCreateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: locationDefaultValues, + }); + + const close = () => { + form.reset(locationDefaultValues); + createLocation.reset(); + onClose(); + }; + + const onSubmit = (values: LocationFormValues) => { + createLocation.mutate(values, { + onSuccess: close, + onError: (error) => { + if (isDuplicateSiteCodeError(error)) { + form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE }); + } + }, + }); + }; + + return ( + { + if (!createLocation.isPending) { + close(); + } + }} + fullWidth + maxWidth="sm" + aria-labelledby="add-site-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + + + Add Site + + Create a new client site record. + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/client-select.tsx b/src/app/(protected)/locations/_components/client-select.tsx new file mode 100644 index 00000000..514811ba --- /dev/null +++ b/src/app/(protected)/locations/_components/client-select.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { useDebounce } from "@/hooks/use-debounce"; + +type ClientOption = Pick; + +interface ClientSelectProps { + control: Control; + error?: FieldError; + /** Stored client, shown even when it is not in the first page of search results. */ + initialClient?: ClientOption; +} + +/** Searchable client (account) picker bound to `accountId`. */ +export function ClientSelect({ control, error, initialClient }: ClientSelectProps) { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 }); + + const options = useMemo(() => { + const items: ClientOption[] = data?.items ?? []; + const hasInitial = + initialClient !== undefined && + items.some((item) => String(item.id) === String(initialClient.id)); + return initialClient === undefined || hasInitial ? items : [initialClient, ...items]; + }, [data?.items, initialClient]); + + return ( + ( + + options={options} + loading={isLoading} + getOptionLabel={(option) => option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={options.find((option) => String(option.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onBlur={field.onBlur} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + setSearch(value); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx index 9053cbe4..ed3122f0 100644 --- a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -202,7 +202,7 @@ export function LocationContactsFieldArray({ - Contacts + Points of contact diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx deleted file mode 100644 index 0b02c348..00000000 --- a/src/app/(protected)/locations/_components/location-delete-dialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import type { LocationListItem } from "@/domain/locations/types/location"; - -interface LocationDeleteDialogProps { - isPending: boolean; - onClose: () => void; - onConfirm: () => void; - target: LocationListItem | null; -} - -export function LocationDeleteDialog({ - isPending, - onClose, - onConfirm, - target, -}: LocationDeleteDialogProps) { - return ( - - Delete Location - - - Are you sure you want to delete "{target?.name}"? This action cannot be undone. - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx deleted file mode 100644 index 39f22296..00000000 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form"; -import { - Autocomplete, - FormControl, - InputLabel, - MenuItem, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { AccountListItem } from "@/domain/accounts/types/account"; -import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; -import { LocationContactsFieldArray } from "./location-contacts-field-array"; -import { StateSelect } from "./state-select"; - -interface SharedFieldsProps { - control: Control; - errors: FieldErrors; - register: UseFormRegister; -} - -interface LocationDetailsFieldsProps extends SharedFieldsProps { - accountOptions: AccountListItem[]; - isLoadingAccounts: boolean; - onAccountSearchChange: (value: string) => void; -} - -export function LocationDetailsFields({ - accountOptions, - control, - errors, - isLoadingAccounts, - onAccountSearchChange, - register, -}: LocationDetailsFieldsProps) { - return ( - - - Location Information - - - - ( - option.name || String(option.id)} - isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} - value={accountOptions.find((account) => String(account.id) === field.value) ?? null} - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - onAccountSearchChange(value); - } - }} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - - ); -} - -export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { - return ( - - - Contact Information - - - - - ( - - Status - - - )} - /> - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx deleted file mode 100644 index b410d43d..00000000 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router"; -import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; -import { normalizeStateCode } from "@/data/areas"; -import { - locationDefaultValues, - locationSchema, - type LocationFormValues, -} from "@/domain/locations/schemas/location-schema"; -import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; -import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; -import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; -import { useDebounce } from "@/hooks/use-debounce"; -import { LocationContactFields, LocationDetailsFields } from "./location-form-fields"; - -export default function LocationFormPage() { - const { id } = useParams(); - const navigate = useNavigate(); - const isEdit = Boolean(id); - - const [accountSearch, setAccountSearch] = useState(""); - const debouncedAccountSearch = useDebounce(accountSearch, 300); - - const { data: location, isLoading, error } = useLocationDetail(id); - const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({ - search: debouncedAccountSearch, - page: 1, - pageSize: 50, - }); - const createLocation = useCreateLocation(); - const updateLocation = useUpdateLocation(); - - const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]); - - const { - register, - handleSubmit, - reset, - control, - formState: { errors }, - } = useForm({ - resolver: zodResolver(locationSchema), - defaultValues: locationDefaultValues, - }); - - useEffect(() => { - if (location && isEdit) { - reset({ - name: location.name ?? "", - accountId: location.accountId != null ? String(location.accountId) : "", - address: location.address ?? "", - city: location.city ?? "", - state: normalizeStateCode(location.state) ?? "", - zipCode: location.zipCode ?? "", - contacts: - location.contacts && location.contacts.length > 0 - ? location.contacts - : [{ name: location.contact ?? "", phone: location.phone ?? "" }], - contactEmail: location.contactEmail ?? "", - status: (location.status as LocationFormValues["status"]) || "Active", - }); - } - }, [location, isEdit, reset]); - - const isSaving = createLocation.isPending || updateLocation.isPending; - - const onSubmit = (values: LocationFormValues) => { - if (isEdit && id) { - updateLocation.mutate({ id, values }); - return; - } - createLocation.mutate(values); - }; - - if (isEdit && isLoading) { - return ( - - - - ); - } - - return ( - - - - - - {isEdit ? "Edit Location" : "Create a Location"} - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load location"} - - )} - - - - - - - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index bfd36118..a7332a76 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -1,9 +1,6 @@ -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Button, CircularProgress, - IconButton, Paper, Table, TableBody, @@ -11,7 +8,6 @@ import { TableContainer, TableHead, TableRow, - Tooltip, Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; @@ -20,84 +16,82 @@ import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { isLoading: boolean; - onDelete: (row: LocationListItem) => void; - onEdit: (row: LocationListItem) => void; + onOpen: (row: LocationListItem) => void; rows: LocationListItem[]; + selectedId?: string | null; } -export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { +const COLUMN_COUNT = 7; + +export function LocationsTable({ + isLoading, + onOpen, + rows, + selectedId = null, +}: LocationsTableProps) { return ( - Location Name - Address + Site Code + Client + Street Address City State - Phone - Contact - Status - - Actions - + POC Name + POC Phone - {isLoading ? ( + {isLoading && ( - + - ) : rows.length === 0 ? ( + )} + {!isLoading && rows.length === 0 && ( - - No locations found + + No sites found - ) : ( + )} + {!isLoading && rows.map((row) => ( - + onOpen(row)} + className="cursor-pointer" + > + {row.clientName || "—"} {row.address || "—"} {row.city || "—"} - {row.phone || "—"} - {row.status || "—"} - - - onEdit(row)}> - - - - - onDelete(row)} - > - - - - + {row.phone || "—"} - )) - )} + ))}
diff --git a/src/app/(protected)/locations/_components/site-delete-dialog.tsx b/src/app/(protected)/locations/_components/site-delete-dialog.tsx new file mode 100644 index 00000000..51d165b5 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-delete-dialog.tsx @@ -0,0 +1,107 @@ +import { useNavigate } from "react-router"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; +import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders"; +import { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +interface SiteDeleteDialogProps { + open: boolean; + siteId: string; + siteCode: string; + onClose: () => void; + onDeleted: () => void; +} + +const CHECK_FAILED_MESSAGE = + "Couldn't check this site for open work orders. Try again before deleting it."; + +function describeDeletion(siteCode: string, openCount: number): string { + if (openCount === 0) { + return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`; + } + const noun = openCount === 1 ? "work order" : "work orders"; + return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`; +} + +/** Delete confirmation; warns with the open work-order count and links to those work orders. */ +export function SiteDeleteDialog({ + open, + siteId, + siteCode, + onClose, + onDeleted, +}: SiteDeleteDialogProps) { + const navigate = useNavigate(); + const deleteLocation = useDeleteLocation(); + const { + data: openWorkOrders, + isLoading, + isError, + isFetching, + refetch, + } = useSiteOpenWorkOrders(siteId, open); + const openCount = openWorkOrders?.count ?? 0; + const countUnknown = isLoading || isFetching || isError; + const openIds = openWorkOrders?.workOrderIds ?? []; + const openWorkOrdersHref = + openIds.length > 0 && openIds.length >= openCount + ? buildWorkOrderIdsHref(openIds) + : buildSiteOpenWorkOrdersHref(siteId); + + return ( + { + if (!deleteLocation.isPending) { + onClose(); + } + }} + aria-labelledby="site-delete-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + Delete this site? + + {isFetching && !isError && ( + + )} + {isError && {CHECK_FAILED_MESSAGE}} + {!countUnknown && ( + {describeDeletion(siteCode, openCount)} + )} + + + + {isError && ( + + )} + {!countUnknown && openCount > 0 && ( + + )} + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-edit.tsx b/src/app/(protected)/locations/_components/site-detail-edit.tsx new file mode 100644 index 00000000..00355440 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-edit.tsx @@ -0,0 +1,68 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box, Button, Stack } from "@mui/material"; +import { + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; +import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; +import { SiteFormFields } from "./site-form-fields"; +import { toSiteFormValues } from "./site-form-values"; + +interface SiteDetailEditProps { + siteId: string; + location: Location; + /** Back to view mode: after Cancel (no API call) or after a successful save. */ + onDone: () => void; +} + +/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */ +export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) { + const updateLocation = useUpdateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: toSiteFormValues(location), + mode: "onChange", + }); + const { isDirty, isValid } = form.formState; + const initialClient = + location.accountId != null && location.clientName + ? { id: location.accountId, name: location.clientName } + : undefined; + + const onSubmit = (values: LocationFormValues) => { + updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone }); + }; + + return ( + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx new file mode 100644 index 00000000..06e78919 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -0,0 +1,132 @@ +import { useContext, useEffect, useState } from "react"; +import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import type { Location } from "@/domain/locations/types/location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { canDeleteSites } from "@/lib/auth/user-utils"; +import { AuthContext } from "@/providers/auth-context"; +import { SiteDeleteDialog } from "./site-delete-dialog"; +import { SiteDetailEdit } from "./site-detail-edit"; +import { SiteDetailView } from "./site-detail-view"; + +export const SITE_PANEL_WIDTH = 420; + +interface SiteDetailPanelProps { + /** Selected site id; `null` closes the panel. */ + siteId: string | null; + onClose: () => void; +} + +function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) { + return ( + + + + {location?.name || "Site"} + + + {location?.clientName || " "} + + + + + ); +} + +function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) { + const [mode, setMode] = useState<"view" | "edit">("view"); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles); + const { data: location, isLoading, error } = useLocationDetail(siteId); + + return ( + <> + + {isLoading && ( + + + + )} + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load site"} + + )} + {location !== undefined && mode === "edit" && ( + setMode("view")} /> + )} + {location !== undefined && mode === "view" && ( + <> + + + + + {canDelete && ( + + )} + + + setIsDeleteOpen(false)} + onDeleted={onClose} + /> + + )} + + ); +} + +/** + * Non-modal site panel: the table stays interactive, so clicking another row swaps the + * content in place. Escape closes it unless a popup inside already handled the key. + */ +export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) { + const open = siteId !== null; + + useEffect(() => { + if (!open) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !event.defaultPrevented) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + + {siteId !== null && } + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-view.tsx b/src/app/(protected)/locations/_components/site-detail-view.tsx new file mode 100644 index 00000000..9ba956f6 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-view.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Link, Stack, Typography } from "@mui/material"; +import { getAreaForState } from "@/data/areas"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; +import { SiteMapPreview } from "./site-map-preview"; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +function formatState(state?: string): string { + const area = getAreaForState(state); + const code = state?.trim() ?? ""; + if (!code) { + return "—"; + } + return area === null ? code : `${code} · Area: ${area}`; +} + +/** Read-only site details, in the prototype order. */ +export function SiteDetailView({ location }: { location: Location }) { + const contacts = location.contacts ?? []; + + return ( + + {location.name || "—"} + {location.clientName || "—"} + + {Boolean(location.address) && ( + + {location.address} + + )} + {!location.address && "—"} + + + {location.city || "—"} + {formatState(location.state)} + + + {location.sitePhone || "—"} + {location.contactEmail || "—"} + + {contacts.length === 0 && "—"} + + {contacts.map((contact, index) => ( +
  • + {index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name} + {Boolean(contact.phone) && ` · ${contact.phone}`} +
  • + ))} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx new file mode 100644 index 00000000..4412d833 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -0,0 +1,109 @@ +import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form"; +import { Stack, TextField } from "@mui/material"; +import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field"; +import { normalizeStateCode } from "@/data/areas"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { ClientSelect } from "./client-select"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; +import { SiteMapPreview } from "./site-map-preview"; +import { StateSelect } from "./state-select"; + +interface SiteFormFieldsProps { + form: UseFormReturn; + /** Edit mode: Site Code is immutable after creation. */ + siteCodeReadOnly?: boolean; + initialClient?: Pick; +} + +/** Shared Add Site / edit-mode fields, in the prototype order. */ +export function SiteFormFields({ + form, + siteCodeReadOnly = false, + initialClient, +}: SiteFormFieldsProps) { + const { + control, + register, + formState: { errors }, + } = form; + const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] }); + const { field: addressField } = useController({ control, name: "address" }); + const { field: cityField } = useController({ control, name: "city" }); + const { field: stateField } = useController({ control, name: "state" }); + + return ( + + ( + field.onChange(event.target.value.toUpperCase())} + label="Site Code" + placeholder="e.g. AB12" + required={!siteCodeReadOnly} + disabled={siteCodeReadOnly} + error={Boolean(errors.name)} + helperText={ + errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined) + } + slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }} + fullWidth + /> + )} + /> + + { + addressField.onChange(parts.street); + cityField.onChange(parts.city); + stateField.onChange(normalizeStateCode(parts.state) ?? ""); + }} + label="Street Address" + required + requiredMarker + error={Boolean(errors.address)} + helperText={ + errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own." + } + /> + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-values.ts b/src/app/(protected)/locations/_components/site-form-values.ts new file mode 100644 index 00000000..94cb859e --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-values.ts @@ -0,0 +1,24 @@ +import { normalizeStateCode } from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; + +/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */ +export function toSiteFormValues(location: Location): LocationFormValues { + const contacts = + location.contacts && location.contacts.length > 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }]; + + return { + name: location.name ?? "", + accountId: location.accountId != null ? String(location.accountId) : "", + address: location.address ?? "", + city: location.city ?? "", + state: normalizeStateCode(location.state) ?? "", + sitePhone: location.sitePhone ?? "", + contactEmail: location.contactEmail ?? "", + contacts, + zipCode: location.zipCode ?? "", + title: location.title ?? "", + }; +} diff --git a/src/app/(protected)/locations/_components/site-map-preview.tsx b/src/app/(protected)/locations/_components/site-map-preview.tsx new file mode 100644 index 00000000..4ff33a9d --- /dev/null +++ b/src/app/(protected)/locations/_components/site-map-preview.tsx @@ -0,0 +1,39 @@ +import { Box, Typography } from "@mui/material"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps"; + +type SiteMapPreviewProps = Pick; + +/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ +export function SiteMapPreview(props: SiteMapPreviewProps) { + const complete = hasCompleteSiteAddress(props); + + return ( + + + Location preview + + {complete && ( + + )} + {!complete && ( + + + Enter an address to preview the location + + + )} + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx deleted file mode 100644 index f25ea225..00000000 --- a/src/app/(protected)/locations/edit/[id].tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 79674137..6492b96b 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useCallback, useMemo, useState } from "react"; import { Alert, Box, @@ -10,25 +9,24 @@ import { TextField, Typography, } from "@mui/material"; -import type { LocationListItem } from "@/domain/locations/types/location"; -import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; +import { AddSiteDialog } from "./_components/add-site-dialog"; import { AreaFacet } from "./_components/area-facet"; -import { LocationDeleteDialog } from "./_components/location-delete-dialog"; import { LocationsTable } from "./_components/locations-table"; +import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel"; export default function LocationsListPage() { - const navigate = useNavigate(); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); const [selectedAreas, setSelectedAreas] = useState([]); - const [deleteTarget, setDeleteTarget] = useState(null); + const [isAddOpen, setIsAddOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); const debouncedSearch = useDebounce(search, 300); - const deleteLocation = useDeleteLocation(); + const closePanel = useCallback(() => setSelectedId(null), []); const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); @@ -47,18 +45,11 @@ export default function LocationsListPage() { const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; - const handleDeleteConfirm = () => { - if (!deleteTarget) { - return; - } - - deleteLocation.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), - }); - }; - return ( - + - + - Locations + Sites - + {totalCount} total {isFetching && !isLoading && } - + { @@ -109,24 +83,24 @@ export default function LocationsListPage() { setSearch(event.target.value); setPage(0); }} - placeholder="Search locations..." + placeholder="Search sites..." className="min-w-[220px]" /> - {Boolean(error) && ( - {error instanceof Error ? error.message : "Failed to load locations"} + {error instanceof Error ? error.message : "Failed to load sites"} )} navigate(`/locations/edit/${row.id}`)} - onDelete={setDeleteTarget} + selectedId={selectedId} + onOpen={(row) => setSelectedId(String(row.id))} /> - setDeleteTarget(null)} - onConfirm={handleDeleteConfirm} - /> + setIsAddOpen(false)} /> + ); } diff --git a/src/app/(protected)/locations/new.tsx b/src/app/(protected)/locations/new.tsx deleted file mode 100644 index da675675..00000000 --- a/src/app/(protected)/locations/new.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./_components/location-form"; diff --git a/src/app/(protected)/team-members/_components/resend-invite-button.tsx b/src/app/(protected)/team-members/_components/resend-invite-button.tsx new file mode 100644 index 00000000..4bc9bf51 --- /dev/null +++ b/src/app/(protected)/team-members/_components/resend-invite-button.tsx @@ -0,0 +1,26 @@ +import { Mail } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { TeamMemberDetail } from "@/domain/team-members/types/team-member"; +import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite"; + +/** Re-sends the registration invite to an active member who has not registered yet. */ +export function ResendInviteButton({ member }: { member: TeamMemberDetail }) { + const resendInvite = useResendTeamMemberInvite(); + + if (!member.pendingRegistration || member.status !== "Active") { + return null; + } + + return ( + + ); +} diff --git a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx index 7c00b463..dde592a5 100644 --- a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx +++ b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx @@ -37,6 +37,7 @@ import { import { getFormErrors } from "./team-member-permissions"; import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog"; import { canonicalRole, resolveFormColor } from "./team-member-form-options"; +import { ResendInviteButton } from "./resend-invite-button"; import { useTeamMemberDetail, useUpdateTeamMember, @@ -206,6 +207,7 @@ function MemberEditForm({ } function DrawerFooter({ + member, editing, form, setForm, @@ -215,6 +217,7 @@ function DrawerFooter({ onEdit, onSave, }: { + member: TeamMemberDetail; editing: boolean; form: TeamMemberForm; setForm: Dispatch>; @@ -257,7 +260,8 @@ function DrawerFooter({
    ) : ( - + + @@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({ {currentMember !== undefined && ( = { }; export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number]; const DISPATCHER_DEFAULTS = new Set([ "createVendors", "editVendors", diff --git a/src/app/(protected)/uplifts/_components/format-uplift-money.ts b/src/app/(protected)/uplifts/_components/format-uplift-money.ts index 01aa64ff..b40357db 100644 --- a/src/app/(protected)/uplifts/_components/format-uplift-money.ts +++ b/src/app/(protected)/uplifts/_components/format-uplift-money.ts @@ -4,3 +4,7 @@ export function formatUpliftMoney(value: number): string { maximumFractionDigits: 2, })}`; } + +export function isGrantedAmount(value: number | null): value is number { + return value != null && value > 0; +} diff --git a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts new file mode 100644 index 00000000..8d0e8473 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts @@ -0,0 +1,50 @@ +export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const; +export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number]; + +export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab { + return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +const REJECTED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Rejected By", + "Rejected At", + "Rejection Reason", +] as const; + +export const UPLIFT_APPROVAL_HEADER_CELLS: Record = { + pending: PENDING_HEADER_CELLS, + approved: APPROVED_HEADER_CELLS, + rejected: REJECTED_HEADER_CELLS, +}; diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index fb40d7c1..24c62083 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -12,14 +12,24 @@ import { TableRow, Tooltip, } from "@mui/material"; -import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { + formatUpliftMoney, + isGrantedAmount, +} from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { + UPLIFT_APPROVAL_HEADER_CELLS, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { + RejectedEmptyState, + RejectedRowCells, +} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells"; import { Text } from "@/components/ui/text"; import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; -export type UpliftApprovalTab = "pending" | "approved"; - const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; @@ -100,7 +110,7 @@ function PendingRowActions({ disabled={!canDecide || isDecisionPending} onClick={() => onApprove(row)} > - Approve + Approve {formatUpliftMoney(upliftAmount(row))} @@ -211,11 +221,11 @@ function UpliftApprovalRow({ - {formatUpliftMoney(row.requestedNTE)} + {formatUpliftMoney(upliftAmount(row))} - {row.approvedOnWoTotal != null ? ( + {isGrantedAmount(row.approvedOnWoTotal) ? ( {formatUpliftMoney(row.approvedOnWoTotal)} ) : ( @@ -232,65 +242,82 @@ function UpliftApprovalRow({ {row.requestedByVendorName || "—"} - {tab === "pending" ? ( - - - - ) : ( + {tab === "pending" && ( + <> + + + + + + + + )} + {tab === "approved" && ( <> {row.decidedByName || "—"} {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + )} - - {tab === "pending" ? ( - - ) : ( - - )} + {tab === "rejected" && } + + ); +} + +function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) { + if (tab === "rejected") return ; + if (tab === "approved") { + return ( + + No approved uplifts yet + + Uplifts you approve are archived here for reference. + + + ); + } + return ( + + No uplifts waiting for approval. + + ); +} + +function UpliftApprovalsEmptyRow({ + tab, + colSpan, + hasError, +}: { + tab: UpliftApprovalTab; + colSpan: number; + hasError: boolean; +}) { + if (hasError) return null; + return ( + + + ); } -const PENDING_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Waiting", - "Actions", -] as const; - -const APPROVED_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Approved By", - "Approved At", - "Actions", -] as const; - export function UpliftApprovalsTable({ tab, isLoading, + hasError, rows, canRevoke, onOpenDetail, @@ -302,6 +329,7 @@ export function UpliftApprovalsTable({ }: { tab: UpliftApprovalTab; isLoading: boolean; + hasError: boolean; rows: UpliftQueueItem[]; canRevoke: boolean; onOpenDetail: (row: UpliftQueueItem) => void; @@ -311,7 +339,7 @@ export function UpliftApprovalsTable({ onOpenAttachment: (row: UpliftQueueItem) => void; isDecisionPending: boolean; }) { - const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab]; return ( @@ -332,22 +360,7 @@ export function UpliftApprovalsTable({ ) : rows.length === 0 ? ( - - - {tab === "pending" ? ( - - No uplifts waiting for approval. - - ) : ( - - No approved uplifts yet - - Uplifts you approve are archived here for reference. - - - )} - - + ) : ( rows.map((row) => ( ; @@ -23,7 +24,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov <> - + ); } @@ -272,7 +282,7 @@ function PendingModalActions({ disabled={!canDecide || approvePending} onClick={() => onApprove(item)} > - Approve + Approve {formatUpliftMoney(upliftAmount(item))} @@ -385,7 +395,7 @@ function UpliftDetailContent({ Amount - {formatUpliftMoney(item.requestedNTE)} + {formatUpliftMoney(upliftAmount(item))} diff --git a/src/app/(protected)/uplifts/_components/uplift-rejected-cells.tsx b/src/app/(protected)/uplifts/_components/uplift-rejected-cells.tsx new file mode 100644 index 00000000..3820a001 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-rejected-cells.tsx @@ -0,0 +1,28 @@ +import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined"; +import { Box, TableCell } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; + +export function RejectedRowCells({ row }: { row: UpliftQueueItem }) { + return ( + <> + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + {row.decisionNote || "—"} + + + + ); +} + +export function RejectedEmptyState() { + return ( + + + No rejected uplifts. + + ); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index db538443..001f8da7 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - UpliftApprovalsTable, + UPLIFT_APPROVAL_TABS, + isUpliftApprovalTab, type UpliftApprovalTab, -} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { @@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; +import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; const PAGE_SIZE = 25; @@ -39,58 +42,68 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: ); } +const TAB_LABELS: Record = { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +}; + +function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null { + return query.data ? query.data.totalCount : null; +} + +function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) { + return ( + + ); +} + function UpliftApprovalsTabs({ tab, - pendingCount, + counts, onChange, }: { tab: UpliftApprovalTab; - pendingCount: number; + counts: Record; onChange: (_event: unknown, value: string) => void; }) { return ( - - Pending - - - } - /> - + {UPLIFT_APPROVAL_TABS.map((value) => ( + + {TAB_LABELS[value]} + + + } + /> + ))} ); } function UpliftApprovalsPagination({ - tab, count, - pendingPage, - approvedPage, - onPendingPageChange, - onApprovedPageChange, + page, + onPageChange, }: { - tab: UpliftApprovalTab; count: number; - pendingPage: number; - approvedPage: number; - onPendingPageChange: (page: number) => void; - onApprovedPageChange: (page: number) => void; + page: number; + onPageChange: (page: number) => void; }) { return ( - tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) - } + page={page} + onPageChange={(_event, nextPage) => onPageChange(nextPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} /> @@ -108,22 +121,31 @@ function UpliftQueueError({ error }: { error: unknown }) { export default function UpliftQueuePage() { const [tab, setTab] = useState("pending"); - const [pendingPage, setPendingPage] = useState(0); - const [approvedPage, setApprovedPage] = useState(0); + const [pages, setPages] = useState>({ + pending: 0, + approved: 0, + rejected: 0, + }); const { user } = useAuthContext(); const canRevokeUplifts = isAdminUser(user?.userRoles); const pendingQuery = useUpliftsQueue({ status: "Pending", - page: pendingPage + 1, + page: pages.pending + 1, pageSize: PAGE_SIZE, }); const approvedQuery = useUpliftsQueue({ status: "Approved", - page: approvedPage + 1, + page: pages.approved + 1, pageSize: PAGE_SIZE, }); + const rejectedQuery = useUpliftsQueue({ + status: "Rejected", + page: pages.rejected + 1, + pageSize: PAGE_SIZE, + }); + const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery }; const controller = useUpliftApprovalController(); const { @@ -138,13 +160,18 @@ export default function UpliftQueuePage() { openDetail, } = controller; - const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const activeQuery = queries[tab]; const rows = activeQuery.data?.items ?? []; - const pendingCount = pendingQuery.data?.totalCount ?? 0; + const counts = { + pending: knownTotal(pendingQuery), + approved: knownTotal(approvedQuery), + rejected: knownTotal(rejectedQuery), + }; + const activeTotal = counts[tab]; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; const handleTabChange = (_event: unknown, value: string) => { - if (value === "pending" || value === "approved") { + if (isUpliftApprovalTab(value)) { setTab(value); } }; @@ -152,11 +179,12 @@ export default function UpliftQueuePage() { return ( - + - + {activeTotal != null && ( + setPages((current) => ({ ...current, [tab]: page }))} + /> + )} {detailItem != null && ( void; +}; + +/** Contains child margins so a block's measured height is its full height. */ +const flowRoot = { display: "flow-root" } as const; + +/** + * The printed document: A4 sheets stacked in a scrollable column, the letterhead + * on the first sheet only and the brand bars at the top and bottom of every sheet. + */ +export function CompDocDocumentFrame({ + header, + sections, + dimmed = false, + overlay, + onPageChange, +}: CompDocDocumentFrameProps) { + const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } = + useCompDocPagination(sections.length, onPageChange); + + return ( +
    +
    + {pages.map((blockIndices, pageIndex) => ( +
    { + pageRefs.current[pageIndex] = el; + }} + data-testid="comp-doc-page" + className="relative flex flex-col break-words" + style={{ + marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0, + minHeight: pageHeight, + background: "#ffffff", + borderRadius: 8, + boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)", + overflow: "hidden", + fontFamily: "var(--font-sans)", + overflowWrap: "break-word", + wordBreak: "break-word", + }} + > +
    +
    +
    + {pageIndex === 0 && ( +
    + {header} +
    + )} + {blockIndices.map((blockIndex) => ( +
    { + blockRefs.current[blockIndex] = el; + }} + style={flowRoot} + > + {sections[blockIndex]} +
    + ))} +
    +
    +
    + + {pageIndex === 0 && overlay} +
    + ))} +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx index 9df6c499..948be0c2 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx @@ -1,10 +1,15 @@ import { CheckCircle2, FileText } from "lucide-react"; +import { + pageIndicatorLabel, + type PagePosition, +} from "@/domain/work-orders/utils/completion-doc-pagination"; type CompDocPreviewBadgeProps = { isGenerated: boolean; + page: PagePosition; }; -export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) { +export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) { return (
    )} {isGenerated ? "Generated document" : "Preview — not yet generated"} + {pageIndicatorLabel(page)}
    ); diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx index 871468fc..bde55ea7 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx @@ -1,46 +1,20 @@ import { Text } from "@/components/ui/text"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; +import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures"; +import { CompDocProcedureHeading } from "./comp-doc-procedure-heading"; import { CompDocProcedureList } from "./comp-doc-procedure-list"; type CompDocPreviewProcedureProps = { row: WorkOrderTableRow; - notes: string; }; -export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) { +/** The work order's procedure: heading, intro and steps, kept together on one page. */ +export function CompDocPreviewProcedure({ row }: CompDocPreviewProcedureProps) { const proc = procedureFor(row.pm); return ( <> -
      - {SAFETY_BULLETS.map((b, i) => ( -
    • - {b} -
    • - ))} -
    - - - {proc.heading} - + {proc.heading} - - {notes.length > 0 && ( - <> - - PM Comments - - - {notes} - - - )} + + ); +} + +/** The PM's free-text comments printed after the procedure. */ +export function CompDocPreviewComments({ notes }: { notes: string }) { + return ( + <> + PM Comments + + {notes} + ); } diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx index 64e167bc..157e67b8 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx @@ -1,10 +1,14 @@ +import { useState } from "react"; import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { PagePosition } from "@/domain/work-orders/utils/completion-doc-pagination"; +import { CompDocDocumentFrame } from "./comp-doc-document-frame"; import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp"; import { CompDocPreviewBadge } from "./comp-doc-preview-badge"; import { CompDocPreviewHeader } from "./comp-doc-preview-header"; -import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure"; +import { CompDocPreviewComments, CompDocPreviewProcedure } from "./comp-doc-preview-procedure"; import { CompDocPreviewSummary } from "./comp-doc-preview-summary"; +import { CompDocSafetyBullets } from "./comp-doc-safety-bullets"; type CompDocPreviewProps = { row: WorkOrderTableRow; @@ -23,6 +27,10 @@ export function CompDocPreview({ isGenerated, isNotRequired, }: CompDocPreviewProps) { + const [page, setPage] = useState({ current: 1, total: 1 }); + const sections = []; + if (notes.length > 0) sections.push(); + return (
    - + -
    -
    -
    -
    } + onPageChange={setPage} + header={ + <> + + -
    - - - -
    -
    -
    - - {isNotRequired && } -
    -
    + + + } + sections={sections} + />
    ); } diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx new file mode 100644 index 00000000..01c87572 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; + +export function CompDocProcedureHeading({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx new file mode 100644 index 00000000..ff1f99a0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx @@ -0,0 +1,27 @@ +import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; + +type CompDocSafetyBulletsProps = { + /** Template-specific notes printed after the standard bullets. */ + extra?: readonly string[]; +}; + +export function CompDocSafetyBullets({ extra = [] }: CompDocSafetyBulletsProps) { + return ( +
      + {[...SAFETY_BULLETS, ...extra].map((b, i) => ( +
    • + {b} +
    • + ))} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts new file mode 100644 index 00000000..eff1def5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + a4PageHeight, + coverAllBlocks, + currentPageAt, + pageContentHeight, + paginateBlocks, + samePages, + type PagePosition, +} from "@/domain/work-orders/utils/completion-doc-pagination"; + +type PageLayout = { + pages: number[][]; + /** Sheet height in px; undefined until the sheets have a measurable width. */ + pageHeight: number | undefined; +}; + +const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0; + +function allOnOnePage(count: number): number[][] { + return [Array.from({ length: count }, (_, i) => i)]; +} + +/** + * Measures the rendered letterhead and blocks and splits them into A4 sheets. + * Blocks are measured where they are rendered (every sheet has the same width), + * and re-measured after every render and whenever anything observed resizes. + */ +export function useCompDocPagination( + blockCount: number, + onPageChange?: (position: PagePosition) => void, +) { + const scrollerRef = useRef(null); + const sheetsRef = useRef(null); + const headerRef = useRef(null); + const blockRefs = useRef<(HTMLDivElement | null)[]>([]); + const pageRefs = useRef<(HTMLDivElement | null)[]>([]); + const [layout, setLayout] = useState({ pages: [[]], pageHeight: undefined }); + const pages = coverAllBlocks(layout.pages, blockCount); + + const measure = useCallback(() => { + const width = sheetsRef.current?.getBoundingClientRect().width ?? 0; + const next: PageLayout = + width > 0 + ? { + pages: paginateBlocks({ + headerHeight: heightOf(headerRef.current), + blockHeights: Array.from({ length: blockCount }, (_, i) => + heightOf(blockRefs.current[i]), + ), + pageHeight: pageContentHeight(width), + }), + pageHeight: a4PageHeight(width), + } + : { pages: allOnOnePage(blockCount), pageHeight: undefined }; + setLayout((prev) => + samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next, + ); + }, [blockCount]); + + useLayoutEffect(() => { + measure(); + }); + + const onPageChangeRef = useRef(onPageChange); + const totalRef = useRef(pages.length); + const lastReported = useRef(null); + useLayoutEffect(() => { + onPageChangeRef.current = onPageChange; + totalRef.current = pages.length; + }); + + const report = useCallback(() => { + const total = totalRef.current; + const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0); + const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total); + const last = lastReported.current; + if (last?.current === current && last.total === total) return; + lastReported.current = { current, total }; + onPageChangeRef.current?.({ current, total }); + }, []); + + useLayoutEffect(() => { + report(); + }); + + const layoutKey = pages.map((page) => page.join(",")).join("|"); + useEffect(() => { + const observer = new ResizeObserver(() => { + measure(); + report(); + }); + [sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach( + (el) => el && observer.observe(el), + ); + return () => observer.disconnect(); + }, [measure, report, blockCount, layoutKey]); + + return { + pages, + pageHeight: layout.pageHeight, + scrollerRef, + sheetsRef, + headerRef, + blockRefs, + pageRefs, + onScroll: report, + }; +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx index 24e468d1..cd85ad9e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx @@ -5,10 +5,12 @@ import { } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits"; type SlideOverCompletionMediaProps = { isReadOnly: boolean; media: MediaFile[]; + mediaCounts?: WorkOrderMediaKindCounts; mediaPending: boolean; onUploadMedia?: (requests: MediaUploadRequest[]) => void; onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void; @@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = { export function SlideOverCompletionMedia({ isReadOnly, media, + mediaCounts, mediaPending, onUploadMedia, onCategorizeMedia, @@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
    void; @@ -39,6 +41,7 @@ export function SlideOverCompletionTab({ isReadOnly, completion, media, + mediaCounts, mediaPending = false, completionPending = false, onUploadMedia, @@ -59,6 +62,7 @@ export function SlideOverCompletionTab({ = { @@ -53,11 +57,16 @@ type SlideOverExtrasTabProps = { pending?: boolean; avetaRequired?: boolean; workOrderId?: string | number; + /** Photos/videos on the whole work order; Extra Docs shows only its own share. */ + mediaCounts?: WorkOrderMediaKindCounts; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +const UNSUPPORTED_TYPE_MESSAGE = + "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC."; + async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { const tab = window.open("about:blank", "_blank"); if (!tab) { @@ -176,27 +185,35 @@ export function SlideOverExtrasTab({ pending = false, avetaRequired = false, workOrderId, + mediaCounts, onUpload, onRemove, onCategorize, }: SlideOverExtrasTabProps) { const inputRef = useRef(null); const avetaAttached = hasExplicitAvetaFile(uploads); + const screeningRef = useRef(false); + const [screening, setScreening] = useState(false); + const busy = pending || screening; - const addFiles = (files: FileList | null) => { - if (isReadOnly || pending || !files || files.length === 0) return; - const accepted: File[] = []; - for (const file of Array.from(files)) { - if (!isWorkOrderExtraDocTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`); - continue; - } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); - continue; - } - accepted.push(file); + const addFiles = async (files: FileList | null) => { + if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return; + const selected = Array.from(files); + screeningRef.current = true; + setScreening(true); + let screened: WorkOrderUploadScreenResult; + try { + screened = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderExtraDocTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + } finally { + screeningRef.current = false; + setScreening(false); } + const { accepted, rejections } = screened; + for (const message of rejections) toast.error(message); if (accepted.length > 0) { onUpload(accepted); } @@ -228,12 +245,12 @@ export function SlideOverExtrasTab({ <> { - addFiles(event.target.files); + void addFiles(event.target.files); event.target.value = ""; }} /> diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx index c047bfa5..276b383c 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx @@ -22,6 +22,7 @@ import { isWorkOrderUpliftsReadOnly, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; +import { isAdminUser } from "@/lib/auth/user-utils"; import { useAuthContext } from "@/providers/auth-context"; type RevokeTarget = { @@ -153,6 +154,7 @@ export function SlideOverUpliftsTab({ uplift={uplift} readOnly={readOnly} currentUserId={user?.id} + currentUserIsAdmin={isAdminUser(user?.userRoles)} pendingAction={actionPending} onCancel={readOnly ? undefined : () => setCancelTargetId(uplift.id)} onRevoke={() => diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 5bde5578..3f2d8db6 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -17,6 +17,7 @@ import { isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; +import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -130,6 +131,8 @@ export function useSlideOverMediaState({ [detailForRow?.media, ownership, completionHasGallery], ); + const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]); + const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); const mediaPending = mediaBatchPending || @@ -196,6 +199,7 @@ export function useSlideOverMediaState({ return { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 95894acf..227cd1cd 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({ const { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, @@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({ ...readFlags, completionMedia, extrasUploads, + mediaCounts, mediaPending, completionPending, closeDisabled, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 32b9d21e..4a7dfd3f 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -156,6 +156,7 @@ export function WorkOrderSlideOverTabs({ isReadOnly={state.isDocumentsReadOnly} completion={state.detailForRow?.completion} media={state.completionMedia} + mediaCounts={state.mediaCounts} mediaPending={state.mediaPending} completionPending={state.completionPending} onUploadMedia={(requests) => { @@ -191,6 +192,7 @@ export function WorkOrderSlideOverTabs({ > setDraft((prev) => ({ ...prev, dateRange: option.key }))} + onClick={() => + setDraft((prev) => ({ + ...prev, + dateRange: prev.dateRange === option.key ? null : option.key, + })) + } /> ))} - + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
    {ALL_WIZARD_STATUSES.map((status) => ( @@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({ onClick={() => toggleIn("statuses", status)} /> ))} + setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} + />
    diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts index e28e1f10..9004fbf4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts @@ -1,5 +1,5 @@ import type { Location } from "@/domain/locations/types/location"; -import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; @@ -40,6 +40,5 @@ export function resolveLocationId( } export function buildMapsUrl(location: Location): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + return buildSiteMapsUrl(location); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index e415c310..f0a956c3 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -114,6 +114,9 @@ export function WoTableRowServiceCells({ value={row.completedDate} q={search} onSave={(date) => onPatchRow({ completedDate: date })} + onClear={ + row.completedDate && !viewOnly ? () => onPatchRow({ completedDate: "" }) : undefined + } />
    e.stopPropagation()}> diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index 0f96ba9c..4b37f205 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -1,7 +1,11 @@ import type { RefObject } from "react"; import { Upload } from "lucide-react"; import { - formatMediaSizeLimit, + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + MAX_WORK_ORDER_PHOTO_BYTES, + MAX_WORK_ORDER_VIDEO_BYTES, workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -12,6 +16,12 @@ type MediaUploaderDropzoneProps = { onFilesSelected: (files: FileList | null) => void; }; +function mediaContractHint(): string { + const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000); + const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000); + return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`; +} + export function MediaUploaderDropzone({ inputRef, pending, @@ -41,7 +51,7 @@ export function MediaUploaderDropzone({ {pending ? "Uploading…" : "Drag files here or click to browse"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file + Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()} {categorizeAfterUpload ? " · categorize after upload" : ""} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index aed81332..3e08a6dd 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -1,14 +1,18 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { toast } from "react-toastify"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, - isWorkOrderFileTooLarge, + countWorkOrderMediaKinds, isWorkOrderMediaTypeAllowed, + type WorkOrderMediaKindCounts, } from "@/domain/work-orders/utils/work-order-media-limits"; import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; +import { + screenWorkOrderUploads, + type WorkOrderUploadScreenResult, +} from "@/domain/work-orders/utils/screen-work-order-uploads"; import { MediaUploaderDropzone } from "./media-uploader-dropzone"; import { MediaUploaderGrid } from "./media-uploader-grid"; @@ -30,8 +34,12 @@ type MediaUploaderProps = { onChange?: (next: MediaFile[]) => void; readOnly?: boolean; pending?: boolean; + /** Photos/videos on the whole work order when `uploads` is only one surface's share. */ + mediaCounts?: WorkOrderMediaKindCounts; }; +const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV."; + export function MediaUploader({ uploads, onUpload, @@ -42,24 +50,31 @@ export function MediaUploader({ onChange, readOnly = false, pending = false, + mediaCounts, }: MediaUploaderProps) { const inputRef = useRef(null); + const screeningRef = useRef(false); + const [screening, setScreening] = useState(false); - const addFiles = (files: FileList | null) => { - if (readOnly || pending || !files || files.length === 0) return; + const addFiles = async (files: FileList | null) => { + if (readOnly || pending || screeningRef.current || !files || files.length === 0) return; - const accepted: File[] = []; - for (const file of Array.from(files)) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); - continue; - } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); - continue; - } - accepted.push(file); + const selected = Array.from(files); + screeningRef.current = true; + setScreening(true); + let screened: WorkOrderUploadScreenResult; + try { + screened = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderMediaTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + } finally { + screeningRef.current = false; + setScreening(false); } + const { accepted, rejections } = screened; + for (const message of rejections) toast.error(message); if (accepted.length === 0) return; if (onUpload) { @@ -93,9 +108,9 @@ export function MediaUploader({ {!readOnly && ( void addFiles(files)} /> )} void; onRevoke?: () => void; @@ -147,13 +148,15 @@ function UpliftCardActions({ uplift, readOnly, currentUserId, + currentUserIsAdmin, pendingAction, onCancel, onRevoke, }: WorkOrderUpliftListItemProps) { const nowMs = useUpliftClock(); const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); - const showRevoke = canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke); + const showRevoke = + canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) && Boolean(onRevoke); const revokeBlocked = readOnly || pendingAction; return ( diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx index 71cbfd89..673a638d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx @@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { Location } from "@/domain/locations/types/location"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; -function buildMapsUrl(location: Location): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; -} - type WizardStepLocationSiteDetailsProps = { selectedLoc: LocationOption | undefined; locationName: string; @@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({ )} {Boolean(addressPreview) && ( { - setAdvApplied(cloneAdvancedFilters(filters)); + setAdvApplied(resolveAppliedAdvancedFilters(filters)); setPage(0); }; diff --git a/src/app/invite/_components/invite-code-step.tsx b/src/app/invite/_components/invite-code-step.tsx new file mode 100644 index 00000000..ad3a4095 --- /dev/null +++ b/src/app/invite/_components/invite-code-step.tsx @@ -0,0 +1,100 @@ +import { useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box } from "@mui/material"; + +import { Button } from "@/components/ui/button"; +import { FormField } from "@/components/ui/form-field"; +import { Text } from "@/components/ui/text"; +import { TextField } from "@/components/ui/text-field"; +import { + inviteCodeStepSchema, + type InviteCodeStepValues, +} from "@/domain/team-member-invite/schemas/invite-registration-schema"; + +import { InviteStepHeader } from "./invite-step-header"; + +export type InviteCodeStepProps = { + email: string; + isVerifying: boolean; + isResending: boolean; + /** Why the entered code was refused; shown under the field. */ + codeError: string | null; + /** Why a new code could not be sent. */ + resendError: string | null; + onVerify: (code: string) => void; + onResend: () => void; + onCodeChange: () => void; +}; + +export function InviteCodeStep({ + email, + isVerifying, + isResending, + codeError, + resendError, + onVerify, + onResend, + onCodeChange, +}: InviteCodeStepProps) { + const { register, handleSubmit, control } = useForm({ + resolver: zodResolver(inviteCodeStepSchema), + defaultValues: { code: "" }, + }); + + const values = useWatch({ control }); + const canContinue = inviteCodeStepSchema.safeParse(values).success; + const codeField = register("code", { onChange: onCodeChange }); + + return ( + onVerify(form.code))} + className="flex flex-col gap-4" + > + + + + + + +
    + + + {resendError} + +
    + + +
    + ); +} diff --git a/src/app/invite/_components/invite-password-step.tsx b/src/app/invite/_components/invite-password-step.tsx new file mode 100644 index 00000000..8696af09 --- /dev/null +++ b/src/app/invite/_components/invite-password-step.tsx @@ -0,0 +1,101 @@ +import { useForm, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box } from "@mui/material"; + +import { PasswordChecklist } from "@/components/auth/password-checklist"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { FormField } from "@/components/ui/form-field"; +import { PasswordField } from "@/components/ui/password-field"; +import { + invitePasswordStepSchema, + type InvitePasswordStepValues, +} from "@/domain/team-member-invite/schemas/invite-registration-schema"; + +import { InviteStepHeader } from "./invite-step-header"; + +export type InvitePasswordStepProps = { + name: string; + defaultPassword: string; + isSubmitting: boolean; + error: string | null; + onContinue: (password: string) => void; +}; + +export function InvitePasswordStep({ + name, + defaultPassword, + isSubmitting, + error, + onContinue, +}: InvitePasswordStepProps) { + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(invitePasswordStepSchema), + defaultValues: { password: defaultPassword, confirmPassword: defaultPassword }, + mode: "onTouched", + }); + + const values = useWatch({ control }); + const canContinue = invitePasswordStepSchema.safeParse(values).success; + + return ( + onContinue(form.password))} + className="flex flex-col gap-4" + > + + + + + + + + + + + + + {error} + + + + + ); +} diff --git a/src/app/invite/_components/invite-review-step.tsx b/src/app/invite/_components/invite-review-step.tsx new file mode 100644 index 00000000..53d7ed96 --- /dev/null +++ b/src/app/invite/_components/invite-review-step.tsx @@ -0,0 +1,100 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box } from "@mui/material"; +import { CircleCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { FormField } from "@/components/ui/form-field"; +import { Text } from "@/components/ui/text"; +import { TextField } from "@/components/ui/text-field"; +import { + inviteReviewStepSchema, + type InviteReviewStepValues, +} from "@/domain/team-member-invite/schemas/invite-registration-schema"; +import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite"; + +import { InviteStepHeader } from "./invite-step-header"; + +export type InviteReviewStepProps = { + details: TeamMemberInviteDetails; + isFinishing: boolean; + error: string | null; + onFinish: (phone: string) => void; +}; + +function ReadOnlyValue({ label, value }: { label: string; value: string }) { + return ( +
    + + {label} + + + {value} + +
    + ); +} + +export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(inviteReviewStepSchema), + defaultValues: { phone: details.phone ?? "" }, + mode: "onTouched", + }); + + return ( + onFinish(form.phone))} + className="flex flex-col gap-4" + > + + +
    + + + + + + +
    + + + {error} + + + +
    + ); +} diff --git a/src/app/invite/_components/invite-step-header.tsx b/src/app/invite/_components/invite-step-header.tsx new file mode 100644 index 00000000..b6437d27 --- /dev/null +++ b/src/app/invite/_components/invite-step-header.tsx @@ -0,0 +1,46 @@ +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export const INVITE_STEP_COUNT = 3; + +export type InviteStep = 1 | 2 | 3; + +export type InviteStepHeaderProps = { + step: InviteStep; + title: string; + subtitle: string; +}; + +export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) { + return ( +
    +
    + {[1, 2, 3].map((bar) => ( + + ))} +
    + + {`Step ${step} of ${INVITE_STEP_COUNT}`} + + + {title} + + + {subtitle} + +
    + ); +} diff --git a/src/app/invite/_components/invite-wizard.tsx b/src/app/invite/_components/invite-wizard.tsx new file mode 100644 index 00000000..fc15478d --- /dev/null +++ b/src/app/invite/_components/invite-wizard.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { toast } from "react-toastify"; + +import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api"; +import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite"; +import { + useCompleteRegistration, + useSendInviteCode, + useVerifyInviteCode, +} from "@/domain/team-member-invite/use-cases/use-team-member-invite"; + +import { InviteCodeStep } from "./invite-code-step"; +import { InvitePasswordStep } from "./invite-password-step"; +import { InviteReviewStep } from "./invite-review-step"; +import type { InviteStep } from "./invite-step-header"; + +export type InviteWizardProps = { + token: string; + details: TeamMemberInviteDetails; + /** Called when the server reports the invite can no longer be used. */ + onInviteInvalid: () => void; +}; + +export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) { + const [step, setStep] = useState(1); + const [password, setPassword] = useState(""); + const [stepError, setStepError] = useState(null); + const [codeError, setCodeError] = useState(null); + const [resendError, setResendError] = useState(null); + const sendCode = useSendInviteCode(); + const verifyCode = useVerifyInviteCode(); + const complete = useCompleteRegistration(); + + /** Returns true when the failure ended the invite and the wizard should stop. */ + const endedInvite = (error: Error) => { + if (inviteErrorCode(error) !== "invalid_invite") { + return false; + } + onInviteInvalid(); + return true; + }; + + const handlePasswordContinue = (nextPassword: string) => { + setPassword(nextPassword); + setStepError(null); + sendCode.mutate(token, { + onSuccess: () => setStep(2), + onError: (error) => { + if (endedInvite(error)) return; + if (inviteErrorCode(error) === "resend_too_soon") { + setStep(2); + return; + } + setStepError(error.message); + }, + }); + }; + + const handleResend = () => { + setResendError(null); + sendCode.mutate(token, { + onSuccess: () => { + setCodeError(null); + toast.success(`Code resent to ${details.email}`); + }, + onError: (error) => { + if (!endedInvite(error)) setResendError(error.message); + }, + }); + }; + + const handleVerify = (code: string) => { + setCodeError(null); + verifyCode.mutate( + { token, code }, + { + onSuccess: () => setStep(3), + onError: (error) => { + if (!endedInvite(error)) setCodeError(error.message); + }, + }, + ); + }; + + const handleFinish = (phone: string) => { + setStepError(null); + complete.mutate( + { token, password, phone }, + { + onError: (error) => { + if (endedInvite(error)) return; + if (inviteErrorCode(error) === "password_rejected") { + setStep(1); + } + setStepError(error.message); + }, + }, + ); + }; + + if (step === 1) { + return ( + + ); + } + + if (step === 2) { + return ( + setCodeError(null)} + /> + ); + } + + return ( + + ); +} diff --git a/src/app/invite/index.tsx b/src/app/invite/index.tsx new file mode 100644 index 00000000..e041f036 --- /dev/null +++ b/src/app/invite/index.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { Link, useLocation } from "react-router"; +import { Box, CircularProgress } from "@mui/material"; +import { ChevronLeft } from "lucide-react"; + +import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo"; +import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header"; +import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell"; +import { Button } from "@/components/ui/button"; +import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api"; +import { + INVALID_INVITE_MESSAGE, + INVITE_UNREACHABLE_MESSAGE, +} from "@/domain/team-member-invite/types/team-member-invite"; +import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite"; + +import { InviteWizard } from "./_components/invite-wizard"; + +/** The invite email links to `/invite#`; a fragment never reaches a server or a Referer. */ +function readInviteToken(hash: string): string { + return hash.replace(/^#/, "").trim(); +} + +function InviteBody({ token }: { token: string }) { + const details = useTeamMemberInviteDetails(token); + const [inviteEnded, setInviteEnded] = useState(false); + + if (token.length > 0 && details.isPending) { + return ( + + + + ); + } + + if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") { + return ( + + + + + ); + } + + if (inviteEnded || details.data === undefined) { + return ; + } + + return ( + setInviteEnded(true)} + /> + ); +} + +export default function InviteRegistrationPage() { + const { hash } = useLocation(); + const token = readInviteToken(hash); + + return ( + + + + + ); +} diff --git a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx index 1cac5391..c31fc93b 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx @@ -56,6 +56,7 @@ export function DispatchDetailSections({ token={token} dispatchId={dispatchId} documents={data.documents ?? []} + mediaCounts={data.mediaCounts} locked={locked} onChanged={refresh} /> @@ -68,6 +69,7 @@ export function DispatchDetailSections({ dispatchId={dispatchId} currentNte={data.nteAmount} upliftRequests={data.upliftRequests ?? []} + mediaCounts={data.mediaCounts} locked={locked} onChanged={refresh} pollDocument={pollEvidenceDocument} diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx index c9f4845e..8413f418 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Text } from "@/components/ui/text"; import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; +import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation"; import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation"; import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; import { formatDateTime } from "@/lib/time-utils"; @@ -15,6 +16,7 @@ type UpliftRequestCardProps = { onWithdraw: (request: VendorPortalUpliftRequest) => void; onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void; onCancelRevise: () => void; + mediaUsage?: VendorMediaUsage; }; const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]); @@ -23,6 +25,19 @@ function statusClassName(status: string): string { return `vp-uplift-status-${String(status).toLowerCase()}`; } +function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) { + const actionable = !locked && canAct && request.raisedByVendor !== false; + return { + canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status), + canRevise: actionable && request.status === "ChangesRequested", + }; +} + +function requestedTotal(request: VendorPortalUpliftRequest): number { + const requested = Number(request.requestedNTE); + return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested; +} + export function UpliftRequestCard({ request, locked, @@ -33,18 +48,17 @@ export function UpliftRequestCard({ onWithdraw, onRevise, onCancelRevise, + mediaUsage, }: UpliftRequestCardProps) { const [revising, setRevising] = useState(false); - const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status); - const canRevise = !locked && canAct && request.status === "ChangesRequested"; + const { canWithdraw, canRevise } = availableActions(request, locked, canAct); return (
    Requested - ${Number(request.requestedNTE).toFixed(2)} (was $ - {Number(request.currentNTE ?? 0).toFixed(2)}) + ${requestedTotal(request).toFixed(2)} (was ${Number(request.currentNTE ?? 0).toFixed(2)})
    @@ -100,6 +114,7 @@ export function UpliftRequestCard({ initialRequested={String(request.requestedNTE)} initialReason={request.vendorReason ?? ""} hasExistingEvidence={request.evidenceDocumentId != null} + mediaUsage={mediaUsage} submitting={submitting} uploadingPercent={uploadingPercent} errorMessage="" diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index 22ddbffe..30edb260 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Text } from "@/components/ui/text"; -import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation"; +import { + validateVendorDocument, + VENDOR_UPLOAD_ACCEPT, + type VendorMediaUsage, +} from "@/domain/vendor-portal/lib/document-validation"; import { isApprovalRequired, validateUpliftAmount, @@ -13,6 +17,8 @@ type UpliftRequestFormProps = { initialRequested?: string; initialReason?: string; hasExistingEvidence?: boolean; + /** Work-order photo/video usage for the evidence pre-check. */ + mediaUsage?: VendorMediaUsage; submitting: boolean; uploadingPercent: number | undefined; errorMessage: string; @@ -26,6 +32,7 @@ export function UpliftRequestForm({ initialRequested = "", initialReason = "", hasExistingEvidence = false, + mediaUsage, submitting, uploadingPercent, errorMessage, @@ -36,11 +43,14 @@ export function UpliftRequestForm({ const [reason, setReason] = useState(initialReason); const [evidenceFile, setEvidenceFile] = useState(); const [formError, setFormError] = useState(""); + const [checkingEvidence, setCheckingEvidence] = useState(false); + const latestSelection = useRef(0); const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte); const submit = (event: React.FormEvent) => { event.preventDefault(); + if (checkingEvidence) return; const amount = Number.parseFloat(requested); const validationError = validateUpliftAmount(requested, currentNte, reason, { file: evidenceFile, @@ -54,10 +64,14 @@ export function UpliftRequestForm({ onSubmit({ amount, reason: reason.trim(), evidenceFile }); }; - const chooseFile = (file?: File) => { - const validationMessage = file ? validateVendorDocument(file) : undefined; + const chooseFile = async (file?: File) => { + const selection = ++latestSelection.current; + setCheckingEvidence(Boolean(file)); + const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined; + if (selection !== latestSelection.current) return; setFormError(validationMessage ?? ""); setEvidenceFile(file); + setCheckingEvidence(false); }; const shownError = errorMessage || formError; @@ -112,14 +126,16 @@ export function UpliftRequestForm({
    - + chooseFile(event.target.files?.[0])} + accept={VENDOR_UPLOAD_ACCEPT} + onChange={(event) => void chooseFile(event.target.files?.[0])} />
    @@ -134,8 +150,14 @@ export function UpliftRequestForm({ {shownError}
    - {mode === "revise" && ( + {!locked && ( + + )} +
    + + ); +} + type UploadRequest = { file: File; replacementId?: number; @@ -24,12 +90,14 @@ export function VendorPortalDocuments({ dispatchId, documents, locked, + mediaCounts, onChanged, }: VendorPortalDocumentsProps) { const inputRef = useRef(null); const [replacementId, setReplacementId] = useState(); const [progress, setProgress] = useState(); const [validationError, setValidationError] = useState(); + const selectionRef = useRef(0); const uploadMutation = useMutation({ mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) => @@ -51,18 +119,27 @@ export function VendorPortalDocuments({ }); const chooseFile = (documentId?: number) => { + selectionRef.current += 1; setValidationError(undefined); uploadMutation.reset(); setReplacementId(documentId); inputRef.current?.click(); }; - const uploadSelectedFile = (file?: File) => { + const uploadSelectedFile = async (file?: File) => { if (!file) { return; } - const validationMessage = validateVendorDocument(file); + const selection = ++selectionRef.current; + const replaced = documents.find((document) => document.id === replacementId); + const validationMessage = await validateVendorDocument( + file, + vendorMediaUsage(mediaCounts, "completion", replaced?.contentType), + ); + if (selection !== selectionRef.current) { + return; + } if (validationMessage) { setValidationError(validationMessage); return; @@ -83,9 +160,9 @@ export function VendorPortalDocuments({ ref={inputRef} type="file" aria-label="Choose completion document" - accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" + accept={VENDOR_UPLOAD_ACCEPT} hidden - onChange={(event) => uploadSelectedFile(event.target.files?.[0])} + onChange={(event) => void uploadSelectedFile(event.target.files?.[0])} /> {documents.length === 0 && ( @@ -93,45 +170,14 @@ export function VendorPortalDocuments({ )} {documents.map((document) => ( -
    -
    - File - - {document.originalFileName} · v{document.version} - -
    -
    - Security scan - {document.scanStatus} -
    -
    - Review - {document.reviewStatus} -
    - {Boolean(document.rejectionReason) && ( - {document.rejectionReason} - )} -
    - - {!locked && ( - - )} -
    -
    + void vendorPortalApi.downloadDocument(token, dispatchId, document)} + onReplace={() => chooseFile(document.id)} + /> ))} {progress !== undefined && ( @@ -155,6 +201,10 @@ export function VendorPortalDocuments({ > Upload document + + Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and + 90 s · PDFs up to 10 MB +
    )} diff --git a/src/app/v/_components/vendor-portal.css b/src/app/v/_components/vendor-portal.css index 1e57cb16..e670f930 100644 --- a/src/app/v/_components/vendor-portal.css +++ b/src/app/v/_components/vendor-portal.css @@ -190,6 +190,13 @@ margin-top: 12px; } +.vp-hint { + align-self: center; + flex-basis: 100%; + font-size: 12px; + color: var(--muted-foreground); +} + .vp-checklist-item { display: flex; align-items: center; diff --git a/src/components/auth/password-checklist.tsx b/src/components/auth/password-checklist.tsx new file mode 100644 index 00000000..41b5e7ac --- /dev/null +++ b/src/components/auth/password-checklist.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Circle, CircleCheck } from "lucide-react"; + +import { Text } from "@/components/ui/text"; +import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy"; +import { cn } from "@/lib/utils"; + +export type PasswordChecklistProps = { + password: string; + id?: string; + className?: string; +}; + +function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string { + return next + .filter((rule, index) => rule.met !== previous[index]?.met) + .map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`) + .join(". "); +} + +/** + * Live, per-rule feedback for the shared password policy. A polite status region + * announces only the rules whose state changed, so screen-reader users hear + * progress while typing without the whole list being re-read on each keystroke. + */ +export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) { + const results = evaluatePasswordRules(password); + const signature = results.map((rule) => (rule.met ? "1" : "0")).join(""); + const [previous, setPrevious] = useState({ signature, results }); + const [announcement, setAnnouncement] = useState(""); + + if (previous.signature !== signature) { + setPrevious({ signature, results }); + setAnnouncement(describeChanges(previous.results, results)); + } + + return ( + <> +
      + {results.map(({ id: ruleId, label, met }) => ( +
    • + {met ? ( +
    • + ))} +
    + + {announcement} + + + ); +} diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index f8551f09..33ecd666 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = { label?: string; placeholder?: string; required?: boolean; + /** + * Show MUI's required asterisk on the label. Sites marks required fields that + * way; Vendors spells "(required)" in the label and asserts no asterisk. + */ + requiredMarker?: boolean; error?: boolean; helperText?: string; disabled?: boolean; @@ -35,6 +40,7 @@ export function AddressAutocompleteField({ label = "Street Address", placeholder = "Start typing the street address…", required = false, + requiredMarker = false, error = false, helperText, disabled = false, @@ -72,8 +78,7 @@ export function AddressAutocompleteField({ error={error} helperText={helperText} fullWidth - // why: Required is set on the input itself, never via MUI's `required` prop: this codebase spells the - // requirement in the label text and asserts that no generated asterisk renders anywhere. + required={requiredMarker} slotProps={{ ...params.slotProps, htmlInput: { ...params.slotProps?.htmlInput, required }, diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 84aeb606..92ddfcca 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,9 +1,13 @@ import { useState } from "react"; import { Link, useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; +import { CircleUserRound } from "lucide-react"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; -import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; +import { useNavGroups } from "@/components/layout/use-nav-groups"; +import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu"; + +const ACCOUNT_PATH = "/account"; function getNavItemAppearance(isActive: boolean, isHover: boolean) { if (isActive) { @@ -129,6 +133,7 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: const width = getSidebarWidth(collapsed); const { pathname } = useLocation(); const activePath = getActiveNavPath(pathname); + const navGroups = useNavGroups(); return ( ))} + + + + ); diff --git a/src/components/layout/use-nav-groups.ts b/src/components/layout/use-nav-groups.ts new file mode 100644 index 00000000..9dd1e1b9 --- /dev/null +++ b/src/components/layout/use-nav-groups.ts @@ -0,0 +1,20 @@ +import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import { getNavGroups, type NavGroup } from "@/config/menu"; + +/** + * Sidebar groups for the signed-in user. The APPROVALS group appears only + * when the backend's can-approve answer (UpliftService tier-role rule, the + * same authority behind uplift approve/reject) is true for either tier. + * Loading and error states stay closed so the entry never flashes for a + * user the backend would refuse, including when a refetch fails and the + * query still holds an earlier `true`. + */ +export function useNavGroups(): NavGroup[] { + const tier1 = useUpliftsCanApprove(1); + const tier2 = useUpliftsCanApprove(2); + const allows = (query: { data?: boolean; isError?: boolean }) => + query.data === true && !query.isError; + const canReviewUplifts = allows(tier1) || allows(tier2); + + return getNavGroups({ canReviewUplifts }); +} diff --git a/src/components/notifications/notification-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx index 3df626e2..30d27a0e 100644 --- a/src/components/notifications/notification-feed-actions.tsx +++ b/src/components/notifications/notification-feed-actions.tsx @@ -1,3 +1,4 @@ +import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections"; import type { NotificationCenter } from "./use-notification-center"; type NotificationFeedActionsProps = { @@ -6,7 +7,7 @@ type NotificationFeedActionsProps = { export function NotificationFeedActions({ center }: NotificationFeedActionsProps) { const hasUnread = center.unreadIds.size > 0; - const hasItems = center.sections.length > 0; + const hasItems = center.sections.some((section) => section.items.some(isDismissable)); return ( <> diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx index 48215231..701de2c1 100644 --- a/src/components/notifications/notification-feed-list.tsx +++ b/src/components/notifications/notification-feed-list.tsx @@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) { unread={center.unreadIds.has(item.id)} onOpen={center.open} onDismiss={center.dismiss} + onAcknowledge={center.acknowledge} + acknowledging={ + item.target.workOrderId != null && + item.target.workOrderId === center.acknowledgingWorkOrderId + } /> ))} diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index 096fd104..c8606783 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -54,8 +54,37 @@ type NotificationRowProps = { unread: boolean; onOpen: (item: NotificationItem, url?: string) => void; onDismiss: (item: NotificationItem) => void; + onAcknowledge?: (item: NotificationItem) => void; + acknowledging?: boolean; }; +/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */ +function AcknowledgeButton({ + item, + onAcknowledge, + acknowledging = false, +}: Pick) { + return ( + + ); +} + /** A vendor conflict spans several work orders, so each one stays reachable from the row. */ function WorkOrderLinks({ item, onOpen }: Pick) { if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) { @@ -85,7 +114,14 @@ function WorkOrderLinks({ item, onOpen }: Pick )} + {item.rowType === "acknowledge" && ( + + )} ); } diff --git a/src/components/notifications/sla-at-risk-banner.tsx b/src/components/notifications/sla-at-risk-banner.tsx new file mode 100644 index 00000000..d2f6aba1 --- /dev/null +++ b/src/components/notifications/sla-at-risk-banner.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from "react"; +import { AlertTriangle, ChevronRight, X } from "lucide-react"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; +import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; +import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = []; + +/** Toasts each at-risk work order once per session, the first time the feed reports it. */ +function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) { + const { claimSlaToasts } = useNotificationSession(); + + useEffect(() => { + const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id))); + atRisk + .filter((workOrder) => claimed.has(workOrder.id)) + .forEach((workOrder) => + toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }), + ); + }, [atRisk, claimSlaToasts]); +} + +/** + * One line at the top of every page while any of the user's Reactive/Emergency work orders is past + * half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing + * hides them for this session, and a work order that becomes at risk later brings the banner back. + */ +export function SlaAtRiskBanner() { + const navigate = useNavigate(); + const { data } = useNotificationFeed(); + const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession(); + const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS; + useSlaHalfwayToasts(atRisk); + + const shown = useMemo( + () => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)), + [atRisk, slaBannerDismissedIds], + ); + if (shown.length === 0) { + return null; + } + + const ids = shown.map((workOrder) => workOrder.id); + return ( +
    + + +
    + ); +} diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts index a466b257..ccc6a491 100644 --- a/src/components/notifications/use-notification-center.ts +++ b/src/components/notifications/use-notification-center.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useNavigate } from "react-router"; import type { NotificationItem } from "@/domain/notifications/types/notification-feed"; import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url"; import { @@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) { [items, readIds], ); + const acknowledgeBreach = useAcknowledgeSlaBreach(); + const acknowledge = (item: NotificationItem) => { + if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return; + acknowledgeBreach.mutate(item.target.workOrderId); + }; + const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null; + const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => { markRead([item.id]); if (url == null) return; @@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) { error: feed.error, open, dismiss: (item: NotificationItem) => dismiss([item.id]), + acknowledge, + acknowledgingWorkOrderId, dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)), markAllRead: () => markRead(items.map((item) => item.id)), }; diff --git a/src/config/menu.ts b/src/config/menu.ts index c5b6fce4..7fbc9717 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; import { // BarChart2, + BadgeDollarSign, BriefcaseBusiness, Building2, ClipboardList, @@ -11,6 +12,7 @@ import { BarChart3, Star, Users, + Wrench, } from "lucide-react"; export type NavItemDef = { @@ -59,6 +61,13 @@ export const navGroups: NavGroup[] = [ { label: "Sites", icon: MapPin, path: "/locations" }, ], }, + { + title: "DATA MANAGEMENT", + items: [ + { label: "Services", icon: Wrench, path: "/settings/services" }, + { label: "Completion Docs", icon: FileText, path: "/completion-docs" }, + ], + }, { title: "TEAM", items: [{ label: "Team Members", icon: Users, path: "/team-members" }], @@ -69,8 +78,17 @@ export const navGroups: NavGroup[] = [ // }, ]; +const approvalsNavGroup: NavGroup = { + title: "APPROVALS", + items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }], +}; + +export function getNavGroups({ canReviewUplifts }: { canReviewUplifts: boolean }): NavGroup[] { + return canReviewUplifts ? [...navGroups, approvalsNavGroup] : navGroups; +} + export function getActiveNavPath(pathname: string): string | undefined { - return navGroups + return [...navGroups, approvalsNavGroup] .flatMap((group) => group.items) .map((item) => item.path) .filter((path) => diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts index 279baaa7..13451b4a 100644 --- a/src/domain/auth/api/auth-api.ts +++ b/src/domain/auth/api/auth-api.ts @@ -41,11 +41,11 @@ export async function changePassword(data: ChangePasswordPayload): Promise { - return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`); + return apiPost(API_PATHS.authentication.forgetPassword, { email }); } -export async function verifyResetCode(code: string): Promise { - return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`); +export async function verifyResetCode(email: string, code: string): Promise { + return apiPost(API_PATHS.authentication.verificationCode, { email, code }); } export async function resetPassword(data: ResetPasswordPayload): Promise { diff --git a/src/domain/auth/api/permissions-api.ts b/src/domain/auth/api/permissions-api.ts new file mode 100644 index 00000000..292fce4c --- /dev/null +++ b/src/domain/auth/api/permissions-api.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; + +export const UNEXPECTED_PERMISSIONS_RESPONSE = + "The server sent an unexpected permissions response."; + +const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) }); + +/** Keys the signed-in user holds after role defaults and their individual overrides. */ +export async function getMyPermissions(): Promise { + const data = await apiGet(API_PATHS.teamMember.myPermissions); + const result = effectivePermissionsSchema.safeParse(handleApiResponse(data)); + if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE); + return result.data.permissions; +} diff --git a/src/domain/auth/password-policy.ts b/src/domain/auth/password-policy.ts new file mode 100644 index 00000000..b1ed46f8 --- /dev/null +++ b/src/domain/auth/password-policy.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +/** + * The single password rule for every surface that sets a password. It mirrors the + * server's Identity options: the character classes are ASCII, matching how the + * server classifies uppercase letters, digits and non-alphanumeric characters. + */ +export const PASSWORD_MIN_LENGTH = 6; + +export type PasswordRuleId = "length" | "uppercase" | "number" | "special"; + +export type PasswordRule = { + id: PasswordRuleId; + label: string; + test: (password: string) => boolean; +}; + +export type PasswordRuleResult = { + id: PasswordRuleId; + label: string; + met: boolean; +}; + +export const PASSWORD_RULES: readonly PasswordRule[] = [ + { + id: "length", + label: `At least ${PASSWORD_MIN_LENGTH} characters`, + test: (password) => password.length >= PASSWORD_MIN_LENGTH, + }, + { id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) }, + { id: "number", label: "One number", test: (password) => /[0-9]/.test(password) }, + { + id: "special", + label: "One special character", + test: (password) => /[^A-Za-z0-9]/.test(password), + }, +]; + +export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below"; + +export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match"; + +export function evaluatePasswordRules(password: string): PasswordRuleResult[] { + return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) })); +} + +export function meetsPasswordPolicy(password: string): boolean { + return PASSWORD_RULES.every((rule) => rule.test(password)); +} + +export const passwordSchema = z.string().refine(meetsPasswordPolicy, { + message: PASSWORD_REQUIREMENTS_MESSAGE, +}); diff --git a/src/domain/auth/schemas/change-password-schema.ts b/src/domain/auth/schemas/change-password-schema.ts new file mode 100644 index 00000000..be3118f9 --- /dev/null +++ b/src/domain/auth/schemas/change-password-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy"; + +export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect"; + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: passwordSchema, + confirmPassword: z.string(), + }) + .refine((values) => values.newPassword === values.confirmPassword, { + path: ["confirmPassword"], + message: PASSWORDS_DO_NOT_MATCH_MESSAGE, + }); + +export type ChangePasswordFormValues = z.infer; diff --git a/src/domain/auth/use-cases/use-change-password.ts b/src/domain/auth/use-cases/use-change-password.ts index eec15235..c86e7860 100644 --- a/src/domain/auth/use-cases/use-change-password.ts +++ b/src/domain/auth/use-cases/use-change-password.ts @@ -3,14 +3,13 @@ import { toast } from "react-toastify"; import * as authApi from "@/domain/auth/api/auth-api"; import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user"; +/** Errors are rendered inline by the form that initiated the change. */ export function useChangePassword(): UseMutationResult { return useMutation({ mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data), + meta: { suppressErrorToast: true }, onSuccess: () => { - toast.success("Password changed successfully!"); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to change password"); + toast.success("Password updated"); }, }); } diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts index 55766b90..dbd40a4b 100644 --- a/src/domain/auth/use-cases/use-login.ts +++ b/src/domain/auth/use-cases/use-login.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query"; @@ -21,6 +22,13 @@ export function useSession(): UseQueryResult { }); } +/** Makes a freshly stored session the signed-in user, after login or invite registration. */ +export function startSignedInSession(queryClient: QueryClient, user: AuthUser): void { + stopQueryBroadcast(queryClient); + queryClient.setQueryData(queryKeys.auth.session(), user); + startQueryBroadcast(queryClient, user.id); +} + export function useLogin(): UseMutationResult { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult mutationFn: (credentials: LoginCredentials) => authApi.login(credentials), meta: { suppressErrorToast: true }, onSuccess: (user) => { - stopQueryBroadcast(queryClient); - queryClient.setQueryData(queryKeys.auth.session(), user); - startQueryBroadcast(queryClient, user.id); + startSignedInSession(queryClient, user); toast.success("Login successful!"); navigate("/"); }, diff --git a/src/domain/auth/use-cases/use-permissions.ts b/src/domain/auth/use-cases/use-permissions.ts new file mode 100644 index 00000000..dab5649c --- /dev/null +++ b/src/domain/auth/use-cases/use-permissions.ts @@ -0,0 +1,32 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions"; +import { getMyPermissions } from "@/domain/auth/api/permissions-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { useAuthContext } from "@/providers/auth-context"; + +/** + * The signed-in user's effective permissions as the server computes them. Keyed by user so a + * different sign-in never reads another person's answer, and refetched on every mount and window + * focus so an override change applies on the next page load. + */ +export function usePermissions(): UseQueryResult { + const { user } = useAuthContext(); + const userId = user?.id ?? null; + return useQuery({ + queryKey: queryKeys.auth.permissions(userId ?? ""), + queryFn: getMyPermissions, + enabled: userId !== null, + staleTime: 0, + refetchOnWindowFocus: true, + meta: { suppressErrorToast: true }, + }); +} + +/** Holds only for a successful server answer that lists the key; loading and errors deny. */ +export function hasPermission(query: UseQueryResult, key: PermissionKey): boolean { + return !query.isError && query.data?.includes(key) === true; +} + +export function useHasPermission(key: PermissionKey): boolean { + return hasPermission(usePermissions(), key); +} diff --git a/src/domain/completion-templates/api/completion-templates-api.ts b/src/domain/completion-templates/api/completion-templates-api.ts new file mode 100644 index 00000000..14e6d2d8 --- /dev/null +++ b/src/domain/completion-templates/api/completion-templates-api.ts @@ -0,0 +1,64 @@ +import type { z } from "zod"; +import { apiDeleteNoContent, apiGet, apiPost, apiPut } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapCompletionTemplate } from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { + completionTemplateDtoSchema, + completionTemplateListDtoSchema, + linkedWorkOrdersDtoSchema, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { + CompletionTemplate, + CompletionTemplateInput, + CompletionTemplateLegacyFields, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; + +export const UNEXPECTED_TEMPLATE_RESPONSE = "The server sent an unexpected response."; + +function parse(schema: z.ZodType, data: unknown): T { + const result = schema.safeParse(handleApiResponse(data)); + if (!result.success) throw new Error(UNEXPECTED_TEMPLATE_RESPONSE); + return result.data; +} + +function toBody(input: CompletionTemplateInput, legacy?: CompletionTemplateLegacyFields) { + return { ...legacy, ...input }; +} + +export const completionTemplatesApi = { + list: async (search: string): Promise => { + const term = search.trim(); + const data = await apiGet(API_PATHS.workOrder.completionTemplates, { + searchParams: term === "" ? undefined : { search: term }, + }); + return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate); + }, + + create: async (input: CompletionTemplateInput): Promise => { + const data = await apiPost(API_PATHS.workOrder.completionTemplates, toBody(input)); + return mapCompletionTemplate(parse(completionTemplateDtoSchema, data)); + }, + + /** Replaces the whole procedure list and keeps the legacy fields the template already had. */ + update: async ( + template: CompletionTemplate, + input: CompletionTemplateInput, + ): Promise => { + const data = await apiPut( + API_PATHS.workOrder.completionTemplateById(template.id), + toBody(input, template.legacy), + ); + return mapCompletionTemplate(parse(completionTemplateDtoSchema, data)); + }, + + delete: async (id: number): Promise => { + await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id)); + }, + + linkedWorkOrders: async (id: number): Promise => { + const data = await apiGet(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id)); + return parse(linkedWorkOrdersDtoSchema, data); + }, +}; diff --git a/src/domain/completion-templates/constants/completion-template-constants.ts b/src/domain/completion-templates/constants/completion-template-constants.ts new file mode 100644 index 00000000..b1f79c66 --- /dev/null +++ b/src/domain/completion-templates/constants/completion-template-constants.ts @@ -0,0 +1,27 @@ +/** Filled in on every generated document; shown read-only so authors don't retype them. */ +export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const; + +/** Server limits; the inputs stop there instead of failing the save. */ +export const PROCEDURE_NAME_MAX_LENGTH = 200; +export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000; + +export const COMPLETION_TEMPLATE_COPY = { + pageTitle: "Completion Docs", + searchPlaceholder: "Search document title…", + addTemplate: "Add Template", + saveChanges: "Save changes", + titleRequired: "Document Title is required", + noProcedureDetail: "No procedure defined yet", + noProcedurePreview: "No procedure added yet.", + untitledDocument: "UNTITLED COMPLETION DOCUMENT", + autoFilledSuffix: "— auto-filled per work order", + emptySearchTitle: "No templates found", + emptySearchHint: "Try a different search, or add a new template.", + added: "Template added", + updated: "Template updated", + deleted: "Template deleted", +} as const; + +export function templateCountLabel(count: number): string { + return `${count} template${count === 1 ? "" : "s"}`; +} diff --git a/src/domain/completion-templates/mappers/completion-template-mapper.ts b/src/domain/completion-templates/mappers/completion-template-mapper.ts new file mode 100644 index 00000000..4b0188cb --- /dev/null +++ b/src/domain/completion-templates/mappers/completion-template-mapper.ts @@ -0,0 +1,63 @@ +import type { + CompletionTemplateDto, + CompletionTemplateFormValues, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { + CompletionTemplate, + CompletionTemplateInput, +} from "@/domain/completion-templates/types/completion-template"; + +export function mapCompletionTemplate(dto: CompletionTemplateDto): CompletionTemplate { + const createdAt = dto.createdAt ?? ""; + const procedures = [...(dto.procedures ?? [])] + .map((procedure, index) => ({ procedure, order: procedure.sortOrder ?? index })) + .sort((a, b) => a.order - b.order) + .map(({ procedure }) => ({ + name: procedure.name ?? "", + description: procedure.description ?? "", + })); + + return { + id: dto.id, + name: dto.name ?? "", + extraSafetyNote: dto.extraSafetyNote?.trim() ? dto.extraSafetyNote : null, + procedures, + createdAt, + lastUpdatedAt: dto.updatedAt ?? createdAt, + createdByName: dto.createdByName?.trim() ? dto.createdByName : null, + legacy: { + serviceKey: dto.serviceKey ?? "", + workOrderType: dto.workOrderType ?? null, + templateUrl: dto.templateUrl ?? "", + isActive: dto.isActive ?? true, + }, + }; +} + +export function toCompletionTemplateFormValues( + template: CompletionTemplate | null, +): CompletionTemplateFormValues { + if (!template) return { name: "", extraSafetyNote: null, procedures: [] }; + return { + name: template.name, + extraSafetyNote: template.extraSafetyNote, + procedures: template.procedures.map((procedure) => ({ ...procedure })), + }; +} + +/** Trims every field and drops procedures the author left entirely blank. */ +export function toCompletionTemplateInput( + values: CompletionTemplateFormValues, +): CompletionTemplateInput { + const note = values.extraSafetyNote?.trim() ?? ""; + return { + name: values.name.trim(), + extraSafetyNote: note === "" ? null : note, + procedures: values.procedures + .map((procedure) => ({ + name: procedure.name.trim(), + description: procedure.description.trim(), + })) + .filter((procedure) => procedure.name !== "" || procedure.description !== ""), + }; +} diff --git a/src/domain/completion-templates/schemas/completion-template-schema.ts b/src/domain/completion-templates/schemas/completion-template-schema.ts new file mode 100644 index 00000000..7d6605ab --- /dev/null +++ b/src/domain/completion-templates/schemas/completion-template-schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; + +const procedureDtoSchema = z.object({ + name: z.string().nullish(), + description: z.string().nullish(), + sortOrder: z.number().nullish(), +}); + +export const completionTemplateDtoSchema = z.object({ + id: z.number(), + name: z.string().nullish(), + extraSafetyNote: z.string().nullish(), + procedures: z.array(procedureDtoSchema).nullish(), + createdAt: z.string().nullish(), + updatedAt: z.string().nullish(), + createdByName: z.string().nullish(), + serviceKey: z.string().nullish(), + workOrderType: z.number().nullish(), + templateUrl: z.string().nullish(), + isActive: z.boolean().nullish(), +}); + +export const completionTemplateListDtoSchema = z.array(completionTemplateDtoSchema); + +export const linkedWorkOrdersDtoSchema = z.object({ + count: z.number(), + workOrderIds: z.array(z.number()), +}); + +export type CompletionTemplateDto = z.infer; + +const procedureFormSchema = z.object({ + name: z.string(), + description: z.string(), +}); + +export const completionTemplateFormSchema = z.object({ + name: z.string().trim().min(1, COMPLETION_TEMPLATE_COPY.titleRequired), + /** `null` until the author adds the optional note. */ + extraSafetyNote: z.string().nullable(), + procedures: z.array(procedureFormSchema), +}); + +export type CompletionTemplateFormValues = z.infer; diff --git a/src/domain/completion-templates/types/completion-template.ts b/src/domain/completion-templates/types/completion-template.ts new file mode 100644 index 00000000..caf7a6a8 --- /dev/null +++ b/src/domain/completion-templates/types/completion-template.ts @@ -0,0 +1,36 @@ +export interface CompletionTemplateProcedure { + name: string; + description: string; +} + +/** Fields the Services registry still reads; an edit sends them back unchanged. */ +export interface CompletionTemplateLegacyFields { + serviceKey: string; + workOrderType: number | null; + templateUrl: string; + isActive: boolean; +} + +export interface CompletionTemplate { + id: number; + name: string; + extraSafetyNote: string | null; + procedures: CompletionTemplateProcedure[]; + createdAt: string; + /** `updatedAt ?? createdAt`: what the table shows as Last Updated. */ + lastUpdatedAt: string; + createdByName: string | null; + legacy: CompletionTemplateLegacyFields; +} + +export interface CompletionTemplateInput { + name: string; + extraSafetyNote: string | null; + procedures: CompletionTemplateProcedure[]; +} + +/** Open work orders whose completion document would be generated from a template. */ +export interface LinkedWorkOrders { + count: number; + workOrderIds: number[]; +} diff --git a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts new file mode 100644 index 00000000..c5d1998e --- /dev/null +++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts @@ -0,0 +1,84 @@ +import { + useMutation, + useQueryClient, + type QueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { isHTTPError } from "ky"; +import { toast } from "react-toastify"; +import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { + CompletionTemplate, + CompletionTemplateInput, +} from "@/domain/completion-templates/types/completion-template"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateCompletionTemplateVariables { + template: CompletionTemplate; + input: CompletionTemplateInput; +} + +/** The server's blank-title rejection, which the form shows on the title field instead. */ +export function isTitleRequiredError(error: Error): boolean { + if (!isHTTPError(error) || error.response.status !== 422) return false; + const body: unknown = error.data; + return ( + typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired" + ); +} + +function invalidateTemplates(queryClient: QueryClient) { + void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.services.all }); +} + +function toastFailure(error: Error, fallback: string) { + if (isTitleRequiredError(error)) return; + toast.error(error.message || fallback); +} + +export function useCreateCompletionTemplate(): UseMutationResult< + CompletionTemplate, + Error, + CompletionTemplateInput +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CompletionTemplateInput) => completionTemplatesApi.create(input), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.added); + }, + onError: (error: Error) => toastFailure(error, "Failed to add template"), + }); +} + +export function useUpdateCompletionTemplate(): UseMutationResult< + CompletionTemplate, + Error, + UpdateCompletionTemplateVariables +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ template, input }: UpdateCompletionTemplateVariables) => + completionTemplatesApi.update(template, input), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.updated); + }, + onError: (error: Error) => toastFailure(error, "Failed to update template"), + }); +} + +export function useDeleteCompletionTemplate(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => completionTemplatesApi.delete(id), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.deleted); + }, + onError: (error: Error) => toastFailure(error, "Failed to delete template"), + }); +} diff --git a/src/domain/completion-templates/use-cases/use-completion-templates.ts b/src/domain/completion-templates/use-cases/use-completion-templates.ts new file mode 100644 index 00000000..f3101016 --- /dev/null +++ b/src/domain/completion-templates/use-cases/use-completion-templates.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api"; +import type { + CompletionTemplate, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** `search` is a partial, case-insensitive Document Title match done by the server. */ +export function useCompletionTemplates(search = ""): UseQueryResult { + const term = search.trim(); + return useQuery({ + queryKey: queryKeys.completionTemplates.list(term), + queryFn: () => completionTemplatesApi.list(term), + placeholderData: keepPreviousData, + }); +} + +/** Always refetched when asked for: the count gates a destructive action. */ +export function useLinkedWorkOrders( + templateId: number | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0), + queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0), + enabled: templateId !== null, + staleTime: 0, + refetchOnMount: "always", + }); +} diff --git a/src/domain/completion-templates/utils/description-lines.ts b/src/domain/completion-templates/utils/description-lines.ts new file mode 100644 index 00000000..d9a2a047 --- /dev/null +++ b/src/domain/completion-templates/utils/description-lines.ts @@ -0,0 +1,7 @@ +/** A procedure description prints one paragraph per non-blank line. */ +export function descriptionLines(description: string): string[] { + return description + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} diff --git a/src/domain/completion-templates/utils/format-template-date.ts b/src/domain/completion-templates/utils/format-template-date.ts new file mode 100644 index 00000000..042f061b --- /dev/null +++ b/src/domain/completion-templates/utils/format-template-date.ts @@ -0,0 +1,6 @@ +/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */ +export function formatTemplateDate(value: string): string { + const date = new Date(value); + if (value === "" || Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} diff --git a/src/domain/dashboard/api/dashboard-api.ts b/src/domain/dashboard/api/dashboard-api.ts index 5fdd902f..8ab7be8d 100644 --- a/src/domain/dashboard/api/dashboard-api.ts +++ b/src/domain/dashboard/api/dashboard-api.ts @@ -251,6 +251,7 @@ function mapDashboardStats(raw: unknown): DashboardStats { scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")), pendingUplifts: toNumber(readField(data, "pendingUplifts")), avetaPending: toNumber(readField(data, "avetaPending")), + unassigned: toNumber(readField(data, "unassigned")), breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")), }; diff --git a/src/domain/dashboard/types/dashboard-stats.ts b/src/domain/dashboard/types/dashboard-stats.ts index 1394a801..5ff5c5fc 100644 --- a/src/domain/dashboard/types/dashboard-stats.ts +++ b/src/domain/dashboard/types/dashboard-stats.ts @@ -20,6 +20,8 @@ export interface DashboardStats { scheduledTomorrow: number; pendingUplifts: number; avetaPending: number; + /** Open WOs with no dispatcher in the selected period. */ + unassigned: number; breakdown: DashboardBreakdownRow[]; statusDistribution: DashboardStatusBucketRow[]; } diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts index d64eed9b..21b216cb 100644 --- a/src/domain/dashboard/utils/work-order-drilldown-links.ts +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -1,6 +1,7 @@ import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import { addDaysIso, businessTodayIso, @@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch( return search; } +/** + * The Unassigned tile's list: open WOs with no dispatcher in the + * Dashboard period, the same set the backend counts. All time sends no dates, + * so the board searches every week, undated WOs included. + */ +export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED); + search.set("statuses", OPEN_WIZARD_STATUSES.join(",")); + return search; +} + export function scheduledTomorrowDrilldownSearch( today: string = businessTodayIso(), ): URLSearchParams { diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 2f9f1376..728980e8 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -6,10 +6,12 @@ import { mapLocationListItems, mapLocationSites, mapLocationToBackend, + mapSiteOpenWorkOrders, } from "@/domain/locations/mappers/location-mapper"; import type { Location, LocationSite, + SiteOpenWorkOrders, LocationsListParams, LocationsListResult, } from "@/domain/locations/types/location"; @@ -74,4 +76,9 @@ export const locationsApi = { delete: async (id: string | number): Promise => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, + + getOpenWorkOrders: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.locations}/${id}/open-work-orders`); + return mapSiteOpenWorkOrders(handleApiResponse(data)); + }, }; diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 6713c559..3c169f93 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -3,6 +3,7 @@ import type { LocationContact, LocationListItem, LocationSite, + SiteOpenWorkOrders, } from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { @@ -18,6 +19,14 @@ function readString(record: Record, ...keys: string[]): string return ""; } +/** Like readString, but an empty value becomes undefined. */ +function readOptionalString( + record: Record, + ...keys: string[] +): string | undefined { + return readString(record, ...keys) || undefined; +} + function readId(record: Record): string | number { const value = record.id ?? record.Id; if (typeof value === "string" || typeof value === "number") { @@ -123,6 +132,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem { return { id: readId(item), name: readString(item, "name", "Name"), + clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: readString(item, "phone", "Phone"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), state: readString(item, "state", "State"), @@ -154,21 +165,30 @@ export function mapLocation(raw: unknown): Location { const contacts = mapLocationContacts(item.contacts ?? item.Contacts); const mainContact = contacts[0]; - const legacyPhone = readString(item, "phone", "Phone") || undefined; - const legacyContact = - readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + const legacyPhone = readOptionalString(item, "phone", "Phone"); + const legacyContact = readOptionalString( + item, + "contact", + "Contact", + "contactPerson", + "ContactPerson", + ); return { id: readId(item) || undefined, - name: readString(item, "name", "Name") || undefined, - address: address || undefined, - city: readString(item, "city", "City") || undefined, - state: readString(item, "state", "State") || undefined, - zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, + name: readOptionalString(item, "name", "Name"), + title: readOptionalString(item, "title", "Title"), + clientName: readOptionalString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: legacyPhone, + notes: readOptionalString(item, "notes", "Notes"), + address, + city: readOptionalString(item, "city", "City"), + state: readOptionalString(item, "state", "State"), + zipCode: readOptionalString(item, "zipCode", "ZipCode", "Zip"), phone: mainContact ? mainContact.phone : legacyPhone, contact: mainContact ? mainContact.name : legacyContact, - contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, - status: readString(item, "status", "Status") || undefined, + contactEmail: readOptionalString(item, "contactEmail", "ContactEmail"), + status: readOptionalString(item, "status", "Status"), accountId: readOptionalId(item) || undefined, contacts: contacts.length > 0 ? contacts : undefined, }; @@ -196,6 +216,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] { .filter((contact) => contact.name !== "" || contact.phone !== ""); } +/** + * Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`, + * `title` and `notes` are sent only when present so an update keeps the stored values. + */ export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, @@ -206,19 +230,24 @@ export function mapLocationToBackend(location: unknown): Record const mainContact = contacts[0]; const payload: Record = { - name: item.name ?? "", + name: typeof item.name === "string" ? item.name.trim() : "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: mainContact ? mainContact.phone : (item.phone ?? ""), - contact: mainContact ? mainContact.name : (item.contact ?? ""), - contactEmail: item.contactEmail ?? "", - status: item.status ?? "Active", + phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "", + contact: mainContact ? mainContact.name : readString(item, "contact").trim(), + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "", accountId: item.accountId ?? "", contacts, }; + for (const key of ["status", "title", "notes"] as const) { + if (typeof item[key] === "string") { + payload[key] = item[key]; + } + } + const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { payload.id = id; @@ -226,3 +255,16 @@ export function mapLocationToBackend(location: unknown): Record return payload; } + +export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders { + const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const rawIds = item.workOrderIds ?? item.WorkOrderIds; + const workOrderIds = Array.isArray(rawIds) + ? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0) + : []; + const count = item.count ?? item.Count; + return { + count: typeof count === "number" && count >= 0 ? count : workOrderIds.length, + workOrderIds, + }; +} diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 7c9e878c..a45b7b90 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,29 +1,43 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationContactSchema = z.object({ id: z.number().int().positive().optional(), name: z .string() .trim() - .min(1, "Contact person is required") - .max(100, "Contact person cannot exceed 100 characters"), - phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), + .min(1, "Contact name is required") + .max(100, "Contact name cannot exceed 100 characters"), + phone: z + .string() + .trim() + .min(1, "Contact phone is required") + .max(20, "Contact phone cannot exceed 20 characters"), }); +/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */ export const locationSchema = z.object({ - name: z.string().min(1, "Name is required"), - accountId: z.string().optional(), - address: z.string().min(1, "Address is required"), - city: z.string().min(1, "City is required"), + name: z + .string() + .trim() + .min(1, "Site Code is required") + .max(50, "Site Code cannot exceed 50 characters"), + accountId: z.string().min(1, "Client is required"), + address: z.string().trim().min(1, "Street Address is required"), + city: z.string().trim().min(1, "City is required"), state: z .union([z.enum(US_STATE_CODES), z.literal("")]) .refine((state): boolean => state !== "", { message: "State is required" }), - zipCode: z.string().min(1, "Zip code is required"), + sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"), + contactEmail: z + .string() + .trim() + .refine((value) => value === "" || z.email().safeParse(value).success, { + message: "Invalid email address", + }), contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), - contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), - status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), + zipCode: z.string(), + title: z.string(), }); export type LocationFormValues = z.infer; @@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = { address: "", city: "", state: "", - zipCode: "", - contacts: [{ name: "", phone: "" }], + sitePhone: "", contactEmail: "", - status: "Active", + contacts: [{ name: "", phone: "" }], + zipCode: "", + title: "", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 7fa99625..4e64003a 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -10,6 +10,8 @@ export interface LocationContact { export interface LocationListItem { id: string | number; name: string; + clientName: string; + sitePhone: string; address: string; city: string; state: string; @@ -26,6 +28,10 @@ export interface LocationListItem { export interface Location { id?: string | number; name?: string; + title?: string; + clientName?: string; + sitePhone?: string; + notes?: string; address?: string; city?: string; state?: string; @@ -62,4 +68,8 @@ export interface LocationsListResult { pageSize: number; } -export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; +/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */ +export interface SiteOpenWorkOrders { + count: number; + workOrderIds: number[]; +} diff --git a/src/domain/locations/use-cases/use-create-location.ts b/src/domain/locations/use-cases/use-create-location.ts index 421de42c..22bbd202 100644 --- a/src/domain/locations/use-cases/use-create-location.ts +++ b/src/domain/locations/use-cases/use-create-location.ts @@ -1,24 +1,27 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; +import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useCreateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */ +export function useCreateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (values: LocationFormValues) => locationsApi.create(values), + mutationFn: async (values: LocationFormValues) => { + await locationsApi.create({ ...values, status: "Active" }); + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location created successfully!"); - navigate("/locations"); + toast.success("Site added"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create location"); + if (isDuplicateSiteCodeError(error)) { + return; + } + toast.error(error.message || "Failed to add site"); }, }); } diff --git a/src/domain/locations/use-cases/use-delete-location.ts b/src/domain/locations/use-cases/use-delete-location.ts index 4e710337..598b6126 100644 --- a/src/domain/locations/use-cases/use-delete-location.ts +++ b/src/domain/locations/use-cases/use-delete-location.ts @@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult locationsApi.delete(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location deleted successfully!"); + toast.success("Site deleted"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete location"); + toast.error(error.message || "Failed to delete site"); }, }); } diff --git a/src/domain/locations/use-cases/use-site-open-work-orders.ts b/src/domain/locations/use-cases/use-site-open-work-orders.ts new file mode 100644 index 00000000..f405c7ff --- /dev/null +++ b/src/domain/locations/use-cases/use-site-open-work-orders.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { SiteOpenWorkOrders } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */ +export function useSiteOpenWorkOrders( + siteId: string, + enabled: boolean, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.locations.openWorkOrders(siteId), + queryFn: () => locationsApi.getOpenWorkOrders(siteId), + enabled, + staleTime: 0, + }); +} diff --git a/src/domain/locations/use-cases/use-update-location.ts b/src/domain/locations/use-cases/use-update-location.ts index 02e271c6..05d8b874 100644 --- a/src/domain/locations/use-cases/use-update-location.ts +++ b/src/domain/locations/use-cases/use-update-location.ts @@ -1,9 +1,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; import { queryKeys } from "@/infra/query-key/query-key"; interface UpdateLocationInput { @@ -11,20 +9,25 @@ interface UpdateLocationInput { values: LocationFormValues; } -export function useUpdateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Saves a site. The response carries no site, so the list and detail are refetched. */ +export function useUpdateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values), - onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) }); - toast.success("Location updated successfully!"); - navigate("/locations"); + mutationFn: async ({ id, values }: UpdateLocationInput) => { + await locationsApi.update(id, values); + }, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ + queryKey: queryKeys.locations.detail(String(variables.id)), + }), + ]); + toast.success("Site updated"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update location"); + toast.error(error.message || "Failed to update site"); }, }); } diff --git a/src/domain/locations/utils/site-code-conflict.ts b/src/domain/locations/utils/site-code-conflict.ts new file mode 100644 index 00000000..5c2e8e2a --- /dev/null +++ b/src/domain/locations/utils/site-code-conflict.ts @@ -0,0 +1,14 @@ +import { isHTTPError } from "ky"; + +export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists."; + +/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */ +export function isDuplicateSiteCodeError(error: unknown): boolean { + if (!isHTTPError(error)) { + return false; + } + const data = error.data; + const code = + data && typeof data === "object" ? (data as Record).code : undefined; + return error.response.status === 409 || code === "DuplicateSiteCode"; +} diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts new file mode 100644 index 00000000..28a75792 --- /dev/null +++ b/src/domain/locations/utils/site-maps.ts @@ -0,0 +1,23 @@ +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; + +type SiteAddress = Pick; + +/** Google Maps search link for a site address; opens in a new tab. */ +export function buildSiteMapsUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +/** True once Street, City and State are all filled, so a map preview can render. */ +export function hasCompleteSiteAddress(location: SiteAddress): boolean { + return [location.address, location.city, location.state].every( + (part) => (part?.trim() ?? "") !== "", + ); +} + +/** Key-less Google Maps embed URL for the read-only preview. */ +export function buildSiteMapEmbedUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; +} diff --git a/src/domain/notifications/api/notifications-api.ts b/src/domain/notifications/api/notifications-api.ts index 034556f2..d17bd877 100644 --- a/src/domain/notifications/api/notifications-api.ts +++ b/src/domain/notifications/api/notifications-api.ts @@ -1,4 +1,4 @@ -import { apiGet } from "@/api/api"; +import { apiGet, apiPostNoContent } from "@/api/api"; import { notificationFeedSchema, type NotificationFeed, @@ -7,4 +7,7 @@ import { export const notificationsApi = { feed: async (): Promise => notificationFeedSchema.parse(await apiGet("notifications")), + /** Records who acknowledged the missed response deadline in the work order's audit history. */ + acknowledgeSlaBreach: (workOrderId: number): Promise => + apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`), }; diff --git a/src/domain/notifications/session/notification-session-context.ts b/src/domain/notifications/session/notification-session-context.ts index c5697e97..01ddac7c 100644 --- a/src/domain/notifications/session/notification-session-context.ts +++ b/src/domain/notifications/session/notification-session-context.ts @@ -5,6 +5,11 @@ export type NotificationSession = { readIds: ReadonlySet; dismiss: (ids: string[]) => void; markRead: (ids: string[]) => void; + /** At-risk work orders hidden from the SLA banner for this session. */ + slaBannerDismissedIds: ReadonlySet; + dismissSlaBanner: (workOrderIds: number[]) => void; + /** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */ + claimSlaToasts: (workOrderIds: number[]) => number[]; }; export const NotificationSessionContext = createContext(null); diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx index 87bae800..0703456a 100644 --- a/src/domain/notifications/session/notification-session-provider.tsx +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { NotificationSessionContext } from "./notification-session-context"; -function addAll(current: ReadonlySet, ids: string[]): ReadonlySet { +function addAll(current: ReadonlySet, ids: T[]): ReadonlySet { if (ids.every((id) => current.has(id))) { return current; } @@ -17,6 +17,10 @@ function addAll(current: ReadonlySet, ids: string[]): ReadonlySet>(() => new Set()); const [readIds, setReadIds] = useState>(() => new Set()); + const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState>( + () => new Set(), + ); + const toastedSlaIds = useRef(new Set()); const dismiss = useCallback((ids: string[]) => { setDismissedIds((current) => addAll(current, ids)); @@ -24,10 +28,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode const markRead = useCallback((ids: string[]) => { setReadIds((current) => addAll(current, ids)); }, []); + const dismissSlaBanner = useCallback((workOrderIds: number[]) => { + setSlaBannerDismissedIds((current) => addAll(current, workOrderIds)); + }, []); + const claimSlaToasts = useCallback((workOrderIds: number[]) => { + const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id)); + claimed.forEach((id) => toastedSlaIds.current.add(id)); + return claimed; + }, []); const value = useMemo( - () => ({ dismissedIds, readIds, dismiss, markRead }), - [dismissedIds, readIds, dismiss, markRead], + () => ({ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + }), + [ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + ], ); return ( diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index 0142e6d0..d221309d 100644 --- a/src/domain/notifications/types/notification-feed.ts +++ b/src/domain/notifications/types/notification-feed.ts @@ -50,9 +50,20 @@ const sectionSchema = z.object({ items: z.array(itemSchema), }); +/** A work order past half of its SEV response window, with the server-computed clock. */ +const slaAtRiskSchema = z.object({ + id: z.number(), + number: z.string().nullish(), + severity: z.number(), + startedAt: z.string(), + deadlineAt: z.string(), + percentElapsed: z.number(), +}); + export const notificationFeedSchema = z.object({ generatedAt: z.string(), sections: z.array(sectionSchema), + slaAtRisk: z.array(slaAtRiskSchema).default([]), }); export type NotificationSeverity = z.infer; @@ -60,3 +71,4 @@ export type NotificationTarget = z.infer; export type NotificationItem = z.infer; export type NotificationSection = z.infer; export type NotificationFeed = z.infer; +export type SlaAtRiskWorkOrder = z.infer; diff --git a/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts new file mode 100644 index 00000000..fb1fc62e --- /dev/null +++ b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationsApi } from "@/domain/notifications/api/notifications-api"; +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function isBreachRowFor(workOrderId: number) { + return (item: NotificationItem) => + item.reason === "sla" && + item.rowType === "acknowledge" && + item.target.workOrderId === workOrderId; +} + +/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */ +export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed { + const matches = isBreachRowFor(workOrderId); + return { + ...feed, + sections: feed.sections.map((section) => { + const removed = section.items.filter(matches); + if (removed.length === 0) return section; + return { + ...section, + items: section.items.filter((item) => !matches(item)), + count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0), + }; + }), + }; +} + +export function useAcknowledgeSlaBreach() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId), + onSuccess: (_result, workOrderId) => { + queryClient.setQueryData(queryKeys.notifications.feed(), (feed) => + feed ? withoutSlaBreach(feed, workOrderId) : feed, + ); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }), + }); +} diff --git a/src/domain/notifications/utils/sla-alert-copy.ts b/src/domain/notifications/utils/sla-alert-copy.ts new file mode 100644 index 00000000..8f9e26a2 --- /dev/null +++ b/src/domain/notifications/utils/sla-alert-copy.ts @@ -0,0 +1,20 @@ +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; + +function workOrderLabel(workOrder: Pick): string { + return workOrder.number != null && workOrder.number !== "" + ? `WO #${workOrder.number}` + : `WO ${workOrder.id}`; +} + +/** One line whatever the count, as the top-of-app banner shows it. */ +export function slaBannerText(workOrders: Pick[]): string { + if (workOrders.length === 1) { + return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`; + } + return `${workOrders.length} work orders are at risk of missing their response deadline`; +} + +/** The one-time toast when a work order is first seen past half of its response window. */ +export function slaHalfwayToastText(workOrder: Pick): string { + return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`; +} diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts index d725d828..09701f90 100644 --- a/src/domain/services/api/services-api.ts +++ b/src/domain/services/api/services-api.ts @@ -20,10 +20,11 @@ const SERVICE_WORK_ORDER_TYPE_API = { Reactive: 6, } as const; -const TYPE_TO_API: Record, number> = { +const TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, Reactive: WORK_ORDER_TYPE_API.Reactive, Emergency: WORK_ORDER_TYPE_API.Emergency, + Overdue: WORK_ORDER_TYPE_API.PM, }; function toApiInput(input: ServiceInput) { @@ -51,7 +52,6 @@ export const servicesApi = { }, getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { - if (workOrderType === "Overdue") return []; const data = await apiGet(API_PATHS.services.list, { searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, }); diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts index 6dd8d4ac..f712051e 100644 --- a/src/domain/services/use-cases/use-services-for-work-order-type.ts +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -11,7 +11,6 @@ export function useServicesForWorkOrderType( return useQuery({ queryKey: queryKeys.services.byWorkOrderType(effectiveType), queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), - enabled: effectiveType !== "Overdue", staleTime: 60 * 1000, }); } diff --git a/src/domain/team-member-invite/api/team-member-invite-api.ts b/src/domain/team-member-invite/api/team-member-invite-api.ts new file mode 100644 index 00000000..68610df5 --- /dev/null +++ b/src/domain/team-member-invite/api/team-member-invite-api.ts @@ -0,0 +1,55 @@ +// invariant: the invite token travels only in request bodies, never in a URL path or query. +import { isHTTPError } from "ky"; + +import { apiPost, apiPostNoContent } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import type { + CompleteRegistrationInput, + TeamMemberInviteDetails, + TeamMemberInviteErrorCode, +} from "@/domain/team-member-invite/types/team-member-invite"; +import { getAuthUser, setAuthUser } from "@/lib/auth/auth-storage"; + +export const teamMemberInviteApi = { + resolve: async (token: string): Promise => { + const data = await apiPost(API_PATHS.teamMemberInvite.resolve, { token }); + return handleApiResponse(data); + }, + + sendCode: async (token: string): Promise => { + await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token }); + }, + + verifyCode: async ({ token, code }: { token: string; code: string }): Promise => { + await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code }); + }, + + /** Finishes registration and stores the returned session exactly as login does. */ + complete: async (input: CompleteRegistrationInput): Promise => { + const data = await apiPost(API_PATHS.teamMemberInvite.complete, { + token: input.token, + password: input.password, + phone: input.phone.trim(), + }); + const user = handleApiResponse(data); + setAuthUser(user); + return getAuthUser() ?? user; + }, +}; + +/** The server's failure code for an invite call, when the response carried one. */ +export function inviteErrorCode(error: unknown): TeamMemberInviteErrorCode | null { + if (!isHTTPError(error)) { + return null; + } + + const data: unknown = error.data; + if (typeof data === "object" && data !== null && "code" in data) { + const code = (data as { code: unknown }).code; + return typeof code === "string" ? (code as TeamMemberInviteErrorCode) : null; + } + + return null; +} diff --git a/src/domain/team-member-invite/schemas/invite-registration-schema.ts b/src/domain/team-member-invite/schemas/invite-registration-schema.ts new file mode 100644 index 00000000..b680ea06 --- /dev/null +++ b/src/domain/team-member-invite/schemas/invite-registration-schema.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy"; + +export const CODE_FORMAT_MESSAGE = "Enter the 6-digit code from your email"; +export const PHONE_FORMAT_MESSAGE = "Enter a valid phone number"; + +/** Mirrors the server's accepted phone characters and length. */ +const PHONE_PATTERN = /^[0-9+().\s-]{7,32}$/; + +export const invitePasswordStepSchema = z + .object({ + password: passwordSchema, + confirmPassword: z.string(), + }) + .refine((values) => values.password === values.confirmPassword, { + message: PASSWORDS_DO_NOT_MATCH_MESSAGE, + path: ["confirmPassword"], + }); + +export type InvitePasswordStepValues = z.infer; + +export const inviteCodeStepSchema = z.object({ + code: z + .string() + .trim() + .regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE), +}); + +export type InviteCodeStepValues = z.infer; + +export const inviteReviewStepSchema = z.object({ + phone: z + .string() + .trim() + .refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE), +}); + +export type InviteReviewStepValues = z.infer; diff --git a/src/domain/team-member-invite/types/team-member-invite.ts b/src/domain/team-member-invite/types/team-member-invite.ts new file mode 100644 index 00000000..a661ef49 --- /dev/null +++ b/src/domain/team-member-invite/types/team-member-invite.ts @@ -0,0 +1,34 @@ +/** The server's generic message for any invite that cannot be used; shown before any call too. */ +export const INVALID_INVITE_MESSAGE = + "This invite link is invalid or has expired. Ask your admin to send a new invite."; + +/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */ +export const INVITE_UNREACHABLE_MESSAGE = + "We couldn't reach the server to check your invite. Check your connection and try again."; + +/** The read-only identity an admin set when inviting the member. */ +export interface TeamMemberInviteDetails { + name: string; + role: string; + email: string; + phone: string | null; +} + +export interface CompleteRegistrationInput { + token: string; + password: string; + phone: string; +} + +/** Stable failure codes returned by the anonymous invite endpoints. */ +export type TeamMemberInviteErrorCode = + | "invalid_invite" + | "code_incorrect" + | "code_expired" + | "code_locked" + | "resend_too_soon" + | "resend_limit_reached" + | "code_delivery_failed" + | "email_not_confirmed" + | "password_rejected" + | "invalid_phone"; diff --git a/src/domain/team-member-invite/use-cases/use-team-member-invite.ts b/src/domain/team-member-invite/use-cases/use-team-member-invite.ts new file mode 100644 index 00000000..4fda3a49 --- /dev/null +++ b/src/domain/team-member-invite/use-cases/use-team-member-invite.ts @@ -0,0 +1,74 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; + +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { startSignedInSession } from "@/domain/auth/use-cases/use-login"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api"; +import type { + CompleteRegistrationInput, + TeamMemberInviteDetails, +} from "@/domain/team-member-invite/types/team-member-invite"; + +export const REGISTRATION_COMPLETE_MESSAGE = "Registration complete — welcome to Seahaven"; + +/** Invite errors are shown inline on the wizard step, never as a global toast. */ +const INLINE_ERRORS = { suppressErrorToast: true } as const; + +export function useTeamMemberInviteDetails( + token: string, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.teamMemberInvite.details(token), + queryFn: () => teamMemberInviteApi.resolve(token), + enabled: token.length > 0, + retry: false, + staleTime: Infinity, + gcTime: 0, + meta: INLINE_ERRORS, + }); +} + +export function useSendInviteCode(): UseMutationResult { + return useMutation({ + mutationFn: teamMemberInviteApi.sendCode, + meta: INLINE_ERRORS, + }); +} + +export function useVerifyInviteCode(): UseMutationResult< + void, + Error, + { token: string; code: string } +> { + return useMutation({ + mutationFn: teamMemberInviteApi.verifyCode, + meta: INLINE_ERRORS, + }); +} + +export function useCompleteRegistration(): UseMutationResult< + AuthUser, + Error, + CompleteRegistrationInput +> { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: teamMemberInviteApi.complete, + meta: INLINE_ERRORS, + onSuccess: (user) => { + startSignedInSession(queryClient, user); + toast.success(REGISTRATION_COMPLETE_MESSAGE); + navigate("/", { replace: true }); + }, + }); +} diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index 0e53e989..c1462a69 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api"; import { API_PATHS } from "@/api/api-paths"; import { handleApiResponse } from "@/api/handle-api-response"; import { @@ -39,4 +39,9 @@ export const teamMembersApi = { ); return mapTeamMemberDetail(handleApiResponse(data)); }, + + /** Emails a fresh invite link and revokes every earlier one for this pending member. */ + resendInvite: async (id: string | number): Promise => { + await apiPostNoContent(API_PATHS.teamMember.invite(id)); + }, }; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index d283da28..a01a8393 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -88,9 +88,11 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[ export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail { const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const listItem = mapTeamMemberListItem(record); + const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent; return { ...listItem, serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"), permissions: readPermissions(record), + ...(typeof inviteEmailSent === "boolean" && { inviteEmailSent }), }; } diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 5b2d07c5..975eb697 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -32,6 +32,8 @@ export interface TeamMemberCreated { phone: string; serviceAreas: string[]; pendingRegistration: boolean; + /** Present on create only: false means the member exists but the invite email failed. */ + inviteEmailSent?: boolean; } export type TeamPermissionState = 0 | 1 | 2; @@ -45,6 +47,8 @@ export interface TeamPermissionValue { export interface TeamMemberDetail extends TeamMemberListItem { serviceAreas: string[]; permissions: TeamPermissionValue[]; + /** Set only after an update that changed a pending member's email and re-sent their invite. */ + inviteEmailSent?: boolean; } export interface UpdateTeamMemberInput { diff --git a/src/domain/team-members/use-cases/use-create-team-member.ts b/src/domain/team-members/use-cases/use-create-team-member.ts index bf025222..de10fe90 100644 --- a/src/domain/team-members/use-cases/use-create-team-member.ts +++ b/src/domain/team-members/use-cases/use-create-team-member.ts @@ -7,6 +7,9 @@ import type { } from "@/domain/team-members/types/team-member"; import { queryKeys } from "@/infra/query-key/query-key"; +export const INVITE_EMAIL_FAILED_MESSAGE = + "Team member created, but the invite email couldn't be sent. Open their details and use Resend invite."; + export function useCreateTeamMember(): UseMutationResult< TeamMemberCreated, Error, @@ -16,8 +19,12 @@ export function useCreateTeamMember(): UseMutationResult< return useMutation({ mutationFn: teamMembersApi.create, - onSuccess: () => { + onSuccess: (created) => { void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); + if (created.inviteEmailSent === false) { + toast.warning(INVITE_EMAIL_FAILED_MESSAGE); + return; + } toast.success("Team member created with pending registration."); }, onError: (error: Error) => { diff --git a/src/domain/team-members/use-cases/use-resend-team-member-invite.ts b/src/domain/team-members/use-cases/use-resend-team-member-invite.ts new file mode 100644 index 00000000..e2d6830f --- /dev/null +++ b/src/domain/team-members/use-cases/use-resend-team-member-invite.ts @@ -0,0 +1,19 @@ +import { useMutation, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; + +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; + +export type ResendInviteInput = { + id: string | number; + email: string; +}; + +/** Failures surface through the global mutation error toast with the server's message. */ +export function useResendTeamMemberInvite(): UseMutationResult { + return useMutation({ + mutationFn: ({ id }: ResendInviteInput) => teamMembersApi.resendInvite(id), + onSuccess: (_data, { email }) => { + toast.success(`Invite sent to ${email}`); + }, + }); +} diff --git a/src/domain/team-members/use-cases/use-team-member-detail.ts b/src/domain/team-members/use-cases/use-team-member-detail.ts index 3622ab5c..c54b1f7c 100644 --- a/src/domain/team-members/use-cases/use-team-member-detail.ts +++ b/src/domain/team-members/use-cases/use-team-member-detail.ts @@ -23,6 +23,15 @@ export function useTeamMemberDetail( }); } +export const UPDATED_INVITE_EMAIL_FAILED_MESSAGE = + "Team member updated, but the invite to their new email couldn't be sent. Use Resend invite."; + +function updatedMessage(member: TeamMemberDetail): string { + return member.inviteEmailSent === true + ? `Team member updated. A new invite was sent to ${member.email}.` + : "Team member updated."; +} + interface UpdateVariables { id: string | number; input: UpdateTeamMemberInput; @@ -36,7 +45,11 @@ export function useUpdateTeamMember(): UseMutationResult { void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member); - toast.success("Team member updated."); + if (member.inviteEmailSent === false) { + toast.warning(UPDATED_INVITE_EMAIL_FAILED_MESSAGE); + return; + } + toast.success(updatedMessage(member)); }, onError: (error: Error) => { toast.error(error.message || "Failed to update team member"); diff --git a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts index f02e7664..bbf7bbce 100644 --- a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -5,6 +5,7 @@ import { useRejectUplift, useRevokeUplift, } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount"; export function useUpliftApprovalController() { const [detailItem, setDetailItem] = useState(null); @@ -15,7 +16,7 @@ export function useUpliftApprovalController() { const revokeUplift = useRevokeUplift(); const handleApprove = (row: UpliftQueueItem) => { - approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber }); + approveUplift.mutate({ id: row.id, amount: upliftAmount(row), woNumber: row.woNumber }); setDetailItem(null); }; @@ -35,7 +36,7 @@ export function useUpliftApprovalController() { { id: rejectTarget.id, note: reason, - amount: rejectTarget.delta, + amount: upliftAmount(rejectTarget), woNumber: rejectTarget.woNumber, }, { onSuccess: () => setRejectTarget(null) }, @@ -48,7 +49,7 @@ export function useUpliftApprovalController() { { upliftId: revokeTarget.id, reason, - amount: revokeTarget.delta, + amount: upliftAmount(revokeTarget), woNumber: revokeTarget.woNumber, }, { onSuccess: () => setRevokeTarget(null) }, diff --git a/src/domain/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts index 9ebcf4bd..a38386ea 100644 --- a/src/domain/uplifts/use-cases/use-uplifts-queue.ts +++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts @@ -10,5 +10,6 @@ export function useUpliftsQueue( queryKey: queryKeys.uplifts.queue(params as Record), queryFn: () => upliftsApi.list(params), placeholderData: keepPreviousData, + meta: { suppressErrorToast: true }, }); } diff --git a/src/domain/uplifts/utils/uplift-amount.ts b/src/domain/uplifts/utils/uplift-amount.ts new file mode 100644 index 00000000..bd0110db --- /dev/null +++ b/src/domain/uplifts/utils/uplift-amount.ts @@ -0,0 +1,11 @@ +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; + +/** + * The amount of an uplift: the increase being requested on top of the work + * order's current NTE. Every surface that names "the uplift" (queue Amount, + * Approve label, detail modal, reject/revoke dialogs and decision toasts) + * shows this figure; `requestedNTE` is the resulting NTE total, not the uplift. + */ +export function upliftAmount(request: Pick): number { + return request.delta; +} diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index 3c1f5203..9e914350 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,12 +1,126 @@ -const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; -const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]); +import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; -export function validateVendorDocument(file: File): string | undefined { - if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) { - return "Only PDF, JPG, and PNG files are allowed."; +/** + * Client-confirmed media contract on the vendor portal: photos up to + * 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV). + * PDF documents keep the previous 10 MB document cap. + */ +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_PHOTO_BYTES = 10_000_000; +const MAX_VIDEO_BYTES = 100_000_000; +const MAX_VIDEO_DURATION_SECONDS = 90; + +const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."; + +/** File-picker filter for every vendor portal upload, matching the allowlist below. */ +export const VENDOR_UPLOAD_ACCEPT = + ".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime"; + +type VendorUploadKind = "document" | "photo" | "video"; + +const KIND_BY_MIME: Record = { + "application/pdf": "document", + "image/jpeg": "photo", + "image/jpg": "photo", + "image/png": "photo", + "image/heic": "photo", + "video/mp4": "video", + "video/quicktime": "video", +}; + +const KIND_BY_EXTENSION: Record = { + pdf: "document", + jpg: "photo", + jpeg: "photo", + png: "photo", + heic: "photo", + mp4: "video", + mov: "video", +}; + +function resolveKind(file: File): VendorUploadKind | null { + const name = file.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; +} + +/** Photos/videos already counted against the work order for one upload, with the limits. */ +export type VendorMediaUsage = { + photos: number; + videos: number; + maxPhotos: number; + maxVideos: number; +}; + +/** + * The count basis the server uses for this upload. A completion upload replaces a + * document: the latest one by default (already excluded in `completion*`), or the + * one the vendor picked, which is not counted when it is the same kind. Evidence + * uploads count everything. Undefined when the backend does not report counts. + */ +export function vendorMediaUsage( + counts: VendorPortalMediaCounts | null | undefined, + purpose: "completion" | "evidence", + replacedContentType?: string, +): VendorMediaUsage | undefined { + if (!counts) return undefined; + const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos }; + if (purpose === "evidence") { + return { ...limits, photos: counts.photos, videos: counts.videos }; + } + if (replacedContentType === undefined) { + return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos }; + } + const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()]; + return { + ...limits, + photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)), + videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)), + }; +} + +function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined { + if (kind === "photo" && usage.photos >= usage.maxPhotos) { + return `A work order can have at most ${usage.maxPhotos} photos.`; + } + if (kind === "video" && usage.videos >= usage.maxVideos) { + return `A work order can have at most ${usage.maxVideos} videos.`; + } + return undefined; +} + +export async function validateVendorDocument( + file: File, + usage?: VendorMediaUsage, +): Promise { + const kind = resolveKind(file); + if (!kind) { + return UNSUPPORTED_TYPE_MESSAGE; } - if (file.size > MAX_DOCUMENT_BYTES) { + const countMessage = usage && countRejection(kind, usage); + if (countMessage) { + return countMessage; + } + + if (kind === "photo" && file.size > MAX_PHOTO_BYTES) { + return "Photos must be 10 MB or smaller."; + } + + if (kind === "video") { + if (file.size > MAX_VIDEO_BYTES) { + return "Videos must be 100 MB or smaller."; + } + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; + } + } + + if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) { return "Files must be 10 MB or smaller."; } + + return undefined; } diff --git a/src/domain/vendor-portal/lib/uplift-validation.ts b/src/domain/vendor-portal/lib/uplift-validation.ts index b8fafe04..7b3187ed 100644 --- a/src/domain/vendor-portal/lib/uplift-validation.ts +++ b/src/domain/vendor-portal/lib/uplift-validation.ts @@ -48,7 +48,7 @@ export function validateUpliftAmount( return "Please include a reason for the uplift request."; } if (!options.file && !options.hasExisting) { - return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence."; + return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence."; } return ""; } diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index d9853b07..2344d703 100644 --- a/src/domain/vendor-portal/types/vendor-portal.ts +++ b/src/domain/vendor-portal/types/vendor-portal.ts @@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary { refusedAt?: string; } +/** Counts for a new completion version exclude the document it replaces. */ +export interface VendorPortalMediaCounts { + maxPhotos: number; + maxVideos: number; + photos: number; + videos: number; + completionPhotos: number; + completionVideos: number; +} + export interface VendorPortalDocument { id: number; originalFileName: string; @@ -66,6 +76,8 @@ export interface VendorPortalUpliftRequest { requestedNTE: number; currentNTE?: number; vendorReason?: string; + /** False when the request was raised from the work order; the vendor cannot revise or withdraw it. */ + raisedByVendor?: boolean; requestedAt?: string; decidedAt?: string; decidedByName?: string; @@ -137,4 +149,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary upliftRequests: VendorPortalUpliftRequest[]; signoffs: VendorPortalSignoff[]; documents: VendorPortalDocument[]; + /** Per-work-order photo/video usage; absent until the backend reports it. */ + mediaCounts?: VendorPortalMediaCounts | null; } diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 35669740..362893df 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -48,7 +48,11 @@ import { type WorkOrdersListParams, type WorkOrdersListResult, } from "@/domain/work-orders/types/work-order-list"; -import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; +import { + ADVANCED_SEARCH_ALL_WEEKS_FROM, + ADVANCED_SEARCH_ALL_WEEKS_TO, + buildBoardSearchParamsRecord, +} from "@/domain/work-orders/utils/board-query-params"; function buildSearchParams(params: WorkOrdersListParams): Record { const searchParams: Record = { @@ -225,6 +229,10 @@ export const workOrdersApi = { } const result = await workOrdersApi.searchBoard({ search: normalized, + datePreset: "Custom", + dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, + dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, page, pageSize, }); diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index e45381e7..b4940692 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -1,4 +1,5 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; +import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; @@ -64,7 +65,7 @@ export function assignWizardServicePocFields( payload.vendorNotes = vendorNotes; } - if (draft.severity != null) { + if (draft.severity != null && typeNeedsSeverity(draft.type)) { payload.severity = draft.severity; } diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 54b333ed..a1034796 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -233,7 +233,7 @@ function cryptoRandomId(): string { function classifyFileName(fileName: string, url: string): FileCategory { const name = `${fileName} ${url}`.toLowerCase(); - if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) { + if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) { return "image"; } if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) { diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index af00a55d..f1f046f9 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - Overdue: WORK_ORDER_TYPE_API.Other, + Overdue: WORK_ORDER_TYPE_API.Overdue, }; /** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */ diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index c187aee8..6b8ba659 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -23,7 +23,8 @@ export type DateRangeKey = export type CompletionDocFilter = "No" | "Yes" | "NN"; export interface WorkOrderAdvancedFilters { - dateRange: DateRangeKey; + /** `null` = no date range selected: search every week. */ + dateRange: DateRangeKey | null; customFrom: string; customTo: string; /** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */ @@ -34,6 +35,8 @@ export interface WorkOrderAdvancedFilters { dispatchers: Set; regions: Set; statuses: Set; + /** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */ + pastDue: boolean; rescheduled: boolean; carriedOver: boolean; addOn: boolean; @@ -45,10 +48,12 @@ export interface WorkOrderAdvancedFilters { internalOnly: boolean; hasUplift: boolean; upliftStatuses: Set; + /** Exact work orders to show; when non-empty the server ignores every other filter. */ + workOrderIds: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", unscheduledOnly: false, @@ -58,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { dispatchers: new Set(), regions: new Set(), statuses: new Set(), + pastDue: false, rescheduled: false, carriedOver: false, addOn: false, @@ -69,6 +75,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { internalOnly: false, hasUplift: false, upliftStatuses: new Set(), + workOrderIds: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 3b06e4fb..6bfb0e28 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = { Inspection: 5, Reactive: 6, AddOn: 7, + Overdue: 8, Other: 99, } as const; @@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", + [WORK_ORDER_TYPE_API.Overdue]: "Overdue", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { @@ -318,9 +320,16 @@ export interface WorkOrderBoardSearchQueryParams { * legacy wire behavior. */ unscheduledOnly?: boolean; + /** + * `true` adds open rows with no date to the range. Sent only when no + * date range is selected, so an explicit range strictly narrows the results. + */ + includeDateless?: boolean; hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; + /** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */ + ids?: string; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; } diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index 2a8194bb..bb018d1d 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -5,7 +5,9 @@ import { type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query"; +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { toast } from "react-toastify"; +import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift"; import type { @@ -15,6 +17,25 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; +const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift"; +const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 403, 404, 500]); + +function createUpliftErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const status = error.response.status; + if (status === 403 && error.message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) { + return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE; + } + return CREATE_UPLIFT_FIXED_STATUS_COPY.has(status) + ? mapHttpStatusToMessage(status) + : CREATE_UPLIFT_ERROR_FALLBACK; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return CREATE_UPLIFT_ERROR_FALLBACK; +} + function invalidateUpliftQueries( queryClient: ReturnType, workOrderId: string | number, @@ -54,7 +75,7 @@ export function useCreateWorkOrderUplift( toast.success("Uplift request created"); }, onError: (error) => { - toast.error(error.message || "Failed to create uplift"); + toast.error(createUpliftErrorMessage(error)); }, }); } diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index edc707a0..1c4e789c 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -60,7 +60,7 @@ function severityLabel(level: WorkOrderSeverity): string { } export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { - if (filters.dateRange !== "this-week") { + if (filters.dateRange !== null) { const value = filters.dateRange === "custom" ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` @@ -71,7 +71,7 @@ export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAd value, clear: (current) => ({ ...current, - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", }), @@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips( clear: (current) => ({ ...current, statuses: new Set() }), }); + if (filters.pastDue) { + chips.push({ + key: "pastdue", + group: "Status", + value: "Past Due", + clear: (current) => ({ ...current, pastDue: false }), + }); + } + pushSetChip({ chips, key: "pm", @@ -306,3 +315,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder clear: clearUplift, }); } + +export function appendWorkOrderIdsChip( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + const count = filters.workOrderIds?.size ?? 0; + if (count === 0) return; + chips.push({ + key: "workOrderIds", + group: "Work orders", + value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`, + clear: (current) => ({ ...current, workOrderIds: new Set() }), + }); +} diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 5217261b..95572593 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -2,29 +2,48 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-o import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { + ADVANCED_SEARCH_ALL_WEEKS_FROM, + ADVANCED_SEARCH_ALL_WEEKS_TO, +} from "@/domain/work-orders/utils/board-query-params"; +import { isTerminalCarryOverStatus } from "@/domain/work-orders/utils/carry-over-display"; +import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils"; type AdvancedFilterPredicate = ( row: WorkOrderTableRow, filters: WorkOrderAdvancedFilters, ) => boolean; +const isOpenUndated = (row: WorkOrderTableRow): boolean => + !row.scheduledOn && !isTerminalCarryOverStatus(row.status); + const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { if (filters.unscheduledOnly) { - return !row.scheduledOn; + return isOpenUndated(row); } - const [from, to] = dateRangeFor(filters); - return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to); + const [from, to] = dateRangeFor(filters) ?? [ + ADVANCED_SEARCH_ALL_WEEKS_FROM, + ADVANCED_SEARCH_ALL_WEEKS_TO, + ]; + if (row.scheduledOn && row.scheduledOn >= from && row.scheduledOn <= to) return true; + if ( + row.scheduleWeekOnly && + Boolean(row.targetWeek) && + row.targetWeek <= to && + addDaysIso(row.targetWeek, 6) >= from + ) { + return true; + } + return filters.dateRange === null && isOpenUndated(row); }; const matchesSite: AdvancedFilterPredicate = (row, filters) => !filters.sites.size || filters.sites.has(row.site); -const matchesType: AdvancedFilterPredicate = (row, filters) => { - if (!filters.types.size) return true; - const overdueMatch = filters.types.has("Overdue") && row.isPastDue; - const typeMatch = filters.types.has(row.type as never); - return overdueMatch || typeMatch; -}; +const matchesType: AdvancedFilterPredicate = (row, filters) => + !filters.types.size || filters.types.has(row.type as never); + +const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue; const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { if (!filters.dispatchers.size) return true; @@ -69,6 +88,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesType, matchesDispatcher, matchesStatus, + matchesPastDue, matchesPmType, matchesVendorTech, matchesDoc, diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 3f8f5ce5..e60a9435 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -12,6 +12,7 @@ import { appendSeverityChip, appendStatusPmVendorDocChips, appendUpliftChip, + appendWorkOrderIdsChip, type AdvancedFilterChip, } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -33,18 +34,21 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte docs: new Set(filters.docs), flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), + workOrderIds: new Set(filters.workOrderIds ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), + pastDue: Boolean(filters.pastDue), }; } const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [ - filters.dateRange !== "this-week", + filters.dateRange !== null, Boolean(filters.sites?.size), Boolean(filters.types?.size), Boolean(filters.severities?.size), Boolean(filters.dispatchers?.size), Boolean(filters.regions?.size), Boolean(filters.statuses?.size), + Boolean(filters.pastDue), filters.rescheduled, filters.carriedOver, filters.addOn, @@ -56,12 +60,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool filters.internalOnly, filters.hasUplift, filters.unscheduledOnly, + Boolean(filters.workOrderIds?.size), ]; export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length; } +/** + * The server honours only the id set when one is sent, so an id selection and panel facets never + * combine: applying any facet replaces the selection with a normal filtered board. + */ +export function resolveAppliedAdvancedFilters( + filters: WorkOrderAdvancedFilters, +): WorkOrderAdvancedFilters { + const next = cloneAdvancedFilters(filters); + const withoutIds = { ...next, workOrderIds: new Set() }; + return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next; +} + function monthStart(y: number, m: number): string { return `${y}-${pad2(m + 1)}-01`; } @@ -70,7 +87,9 @@ function monthEnd(y: number, m: number): string { return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`; } -export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] { +/** Inclusive [from, to] for the selected range; `null` when no range is selected. */ +export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] | null { + if (filters.dateRange === null) return null; const today = todayIso(); const ty = Number(today.slice(0, 4)); const tm = Number(today.slice(5, 7)) - 1; @@ -118,6 +137,7 @@ export function buildAdvancedFilterChips( appendIndicatorChips(chips, filters); appendFlagAndInternalChips(chips, filters); appendUpliftChip(chips, filters); + appendWorkOrderIdsChip(chips, filters); return chips; } @@ -144,6 +164,6 @@ export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrd return cloneFilters(filters); } -export function isDefaultDateRange(dateRange: DateRangeKey): boolean { - return dateRange === "this-week"; +export function isDefaultDateRange(dateRange: DateRangeKey | null): boolean { + return dateRange === null; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 1c09014a..245cbf68 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -1,8 +1,5 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; -import { - EMPTY_ADVANCED_FILTERS, - type WorkOrderAdvancedFilters, -} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { BOARD_DISPATCHER_UNASSIGNED, @@ -15,6 +12,7 @@ import { } from "@/domain/work-orders/types/work-order-board"; import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter"; import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -44,30 +42,19 @@ function mapDispatchers(ids: Iterable): string[] | undefined { return list.length > 0 ? list : undefined; } -/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ -function mapWoTypeFilters(types: Iterable): { - types?: number[]; - overdue?: boolean; -} { +/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */ +function mapWoTypeFilters(types: Iterable): number[] | undefined { const list: number[] = []; - let overdue = false; for (const type of types) { if (type === "All") { continue; } - if (type === "Overdue") { - overdue = true; - continue; - } const api = mapWizardTypeToApi(type as WOType); if (api != null) { list.push(api); } } - return { - types: list.length > 0 ? list : undefined, - overdue: overdue || undefined, - }; + return list.length > 0 ? list : undefined; } function mapStatuses(statuses: Iterable): number[] | undefined { @@ -136,20 +123,23 @@ function mapSearchFacetParams( }; } +/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ +function mapWorkOrderIds(ids: Set | undefined): string | undefined { + const normalized = ids?.size ? normalizeWorkOrderIds(ids) : []; + return normalized.length > 0 ? normalized.join(",") : undefined; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {}; - return { weekStart: bar.weekMonday, weekEnd: weekTo, dispatchers: mapDispatchers(bar.dispatcherIds), - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(bar.types), search: search.trim().length >= 2 ? search.trim() : undefined, }; } @@ -158,17 +148,19 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { datePreset?: WorkOrderBoardSearchQueryParams["datePreset"]; dateFrom?: string; dateTo?: string; + includeDateless?: boolean; } { // why: SH-338: server-side unscheduledOnly owns the date-range exemption; the client must not fake a // Custom window and sends no date params. if (filters.unscheduledOnly) { return {}; } - if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) { + if (filters.dateRange === null) { return { datePreset: "Custom", dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, }; } @@ -202,7 +194,6 @@ export function toBoardSearchQueryParams(input: { BOARD_SEARCH_MAX_PAGE_SIZE, ); - const typeFilters = mapWoTypeFilters(filters.types); const trimmedSearch = input.search?.trim(); const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; @@ -213,9 +204,10 @@ export function toBoardSearchQueryParams(input: { datePreset: date.datePreset, dateFrom: date.dateFrom, dateTo: date.dateTo, + includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(filters.types), + overdue: filters.pastDue || undefined, dispatchers: mapDispatchers(filters.dispatchers), regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), @@ -224,6 +216,7 @@ export function toBoardSearchQueryParams(input: { docStatuses: mapDocStatuses(filters.docs), unscheduledOnly: filters.unscheduledOnly || undefined, ...mapSearchFacetParams(filters), + ids: mapWorkOrderIds(filters.workOrderIds), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; @@ -250,10 +243,6 @@ export function buildBoardSearchParamsRecord( return out; } -export function woTypeFilterIsOverdue(type: string): boolean { - return type === "Overdue"; -} - /** Week-independent GET /board/search params for the pinned Unassigned queue. */ export function toUnassignedQueueQueryParams( page = 0, @@ -267,5 +256,6 @@ export function toUnassignedQueueQueryParams( datePreset: "Custom", dateFrom: UNASSIGNED_QUEUE_DATE_FROM, dateTo: UNASSIGNED_QUEUE_DATE_TO, + includeDateless: true, }; } diff --git a/src/domain/work-orders/utils/completion-doc-pagination.ts b/src/domain/work-orders/utils/completion-doc-pagination.ts new file mode 100644 index 00000000..ea8d9934 --- /dev/null +++ b/src/domain/work-orders/utils/completion-doc-pagination.ts @@ -0,0 +1,107 @@ +/** A4 portrait: sheet height is its width × 297 / 210. */ +export const A4_HEIGHT_RATIO = 297 / 210; +/** Brand-accent bar at the top of every sheet. */ +export const PAGE_TOP_BAR_PX = 12; +/** Brand-accent bar at the bottom of every sheet. */ +export const PAGE_BOTTOM_BAR_PX = 24; +/** Blank space kept between the last block and the bottom bar. */ +export const PAGE_BOTTOM_PADDING_PX = 28; +/** Vertical gap between stacked sheets. */ +export const PAGE_GAP_PX = 20; + +export type PagePosition = { current: number; total: number }; + +/** Sheet height for a sheet of the given width. */ +export function a4PageHeight(width: number): number { + return width * A4_HEIGHT_RATIO; +} + +/** Height a sheet of the given width leaves for the letterhead and blocks. */ +export function pageContentHeight(width: number): number { + return a4PageHeight(width) - PAGE_TOP_BAR_PX - PAGE_BOTTOM_BAR_PX - PAGE_BOTTOM_PADDING_PX; +} + +type PaginateInput = { + /** Height of the letterhead, printed once at the top of the first page. */ + headerHeight: number; + /** Heights of the blocks (procedures, comments) in document order. */ + blockHeights: readonly number[]; + /** Room a sheet has for content between its top bar and bottom padding. */ + pageHeight: number; +}; + +/** + * Assigns blocks to pages, in order. A block that does not fit in what is left + * of the current page starts the next page, unless the current page holds + * nothing yet: a block taller than a whole page then stays on its own page and + * that page grows to show it. Blocks are never split, dropped or reordered. + * Returns block indices per page; there is always at least one page. + */ +export function paginateBlocks({ + headerHeight, + blockHeights, + pageHeight, +}: PaginateInput): number[][] { + const pages: number[][] = [[]]; + let remaining = pageHeight - headerHeight; + let pageHasContent = headerHeight > 0; + + blockHeights.forEach((height, index) => { + if (pageHasContent && height > remaining) { + pages.push([]); + remaining = pageHeight; + } + pages[pages.length - 1].push(index); + remaining -= height; + pageHasContent = true; + }); + + return pages; +} + +/** + * Makes a (possibly stale) page layout cover exactly blocks 0..count-1: indices + * that no longer exist are dropped and new ones join the last page, so a block + * added since the last measurement is still rendered (and then measured). + */ +export function coverAllBlocks(pages: readonly number[][], count: number): number[][] { + const seen = new Set(); + const covered = pages.map((page) => + page.filter((index) => { + if (index >= count || seen.has(index)) return false; + seen.add(index); + return true; + }), + ); + const result = covered.filter((page, i) => i === 0 || page.length > 0); + if (result.length === 0) result.push([]); + for (let index = 0; index < count; index += 1) { + if (!seen.has(index)) result[result.length - 1].push(index); + } + return result; +} + +/** + * The page being read: the last sheet whose top edge (less half the gap + * between sheets) has scrolled to the top of the viewport. + */ +export function currentPageAt(pageTops: readonly number[], scrollTop: number): number { + let current = 1; + pageTops.forEach((top, index) => { + if (top - PAGE_GAP_PX / 2 <= scrollTop) current = index + 1; + }); + return current; +} + +/** The pill suffix, shown only once the document spans more than one page. */ +export function pageIndicatorLabel({ current, total }: PagePosition): string { + return total > 1 ? ` · Page ${current} of ${total}` : ""; +} + +/** Page layouts compare equal when every page holds the same blocks. */ +export function samePages(a: readonly number[][], b: readonly number[][]): boolean { + return ( + a.length === b.length && + a.every((page, i) => page.length === b[i].length && page.every((x, j) => x === b[i][j])) + ); +} diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index f8a37521..9a5b3004 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter"; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -60,9 +61,26 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { ); } +/** Open = every lifecycle status except Completed (Canceled is not a board status). */ +const OPEN_STATUSES = ALL_WIZARD_STATUSES.filter((status) => status !== "Completed"); + +/** Work Orders filtered to one site's open work orders; `sites` takes the location id. */ +export function buildSiteOpenWorkOrdersHref(siteId: string | number): string { + const params = new URLSearchParams({ + sites: String(siteId), + statuses: OPEN_STATUSES.join(","), + }); + return `/workorders?${params.toString()}`; +} + export function parseDashboardDrilldownParams( params: URLSearchParams, ): WorkOrderAdvancedFilters | null { + const workOrderIds = parseWorkOrderIdsParam(params.get("ids")); + if (workOrderIds.length > 0) { + return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) }; + } + const dateFrom = (params.get("dateFrom") ?? "").trim(); const dateTo = (params.get("dateTo") ?? "").trim(); const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); @@ -72,6 +90,7 @@ export function parseDashboardDrilldownParams( const types = parseTypes(params.get("types")); const dispatchers = parseList(params.get("dispatchers")); const regions = parseRegions(params.get("regions")); + const sites = [...new Set(parseList(params.get("sites")))]; const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); const hasUplift = params.get("hasUplift") === "1"; @@ -83,6 +102,7 @@ export function parseDashboardDrilldownParams( types.length, dispatchers.length, regions.length, + sites.length, upliftStatuses.length, hasUplift, ].some(Boolean); @@ -115,6 +135,9 @@ export function parseDashboardDrilldownParams( if (regions.length > 0) { filters.regions = new Set(regions); } + if (sites.length > 0) { + filters.sites = new Set(sites); + } if (upliftStatuses.length > 0) { filters.upliftStatuses = new Set(upliftStatuses); } diff --git a/src/domain/work-orders/utils/screen-work-order-uploads.ts b/src/domain/work-orders/utils/screen-work-order-uploads.ts new file mode 100644 index 00000000..49988f80 --- /dev/null +++ b/src/domain/work-orders/utils/screen-work-order-uploads.ts @@ -0,0 +1,64 @@ +import { + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + resolveWorkOrderUploadKind, + workOrderFileSizeRejection, + type WorkOrderMediaKindCounts, +} from "@/domain/work-orders/utils/work-order-media-limits"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +export type WorkOrderUploadScreen = { + /** Photos and videos already on the work order, across every surface. */ + existing: WorkOrderMediaKindCounts; + isTypeAllowed: (file: File) => boolean; + unsupportedTypeMessage: string; +}; + +export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] }; + +/** + * Browser pre-check shared by every dispatcher upload surface: type, size, + * per-work-order photo/video count (including earlier files in the same selection) + * and video duration. The server enforces the same contract; a video whose duration + * the browser cannot read is never blocked here. + */ +export async function screenWorkOrderUploads( + files: readonly File[], + { existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen, +): Promise { + const counts = { ...existing }; + const accepted: File[] = []; + const rejections: string[] = []; + for (const file of files) { + if (!isTypeAllowed(file)) { + rejections.push(unsupportedTypeMessage); + continue; + } + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + rejections.push(sizeRejection); + continue; + } + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`); + continue; + } + if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`); + continue; + } + if (kind === "video") { + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`); + continue; + } + } + if (kind === "photo") counts.photos += 1; + if (kind === "video") counts.videos += 1; + accepted.push(file); + } + return { accepted, rejections }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 5e1bca25..5372ed74 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -64,12 +64,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { } function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set): boolean { - if (types.size === 0) { - return true; - } - const overdueMatch = types.has("Overdue") && row.isPastDue; - const typeMatch = types.has(row.type as WOType); - return overdueMatch || typeMatch; + return types.size === 0 || types.has(row.type as WOType); } export function filterByBarAndSearch( diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 7a06dcad..7124625f 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -135,11 +135,15 @@ export function upliftAllowanceHelper( export function canRevokeWorkOrderUplift( uplift: Pick, currentUserId: string | number | null | undefined, + currentUserIsAdmin: boolean, ): boolean { if (uplift.status !== "auto_approved") { // why: SH-214/SH-212 keep admin-approved revoke on Uplift Approvals, not this work order panel. return false; } + if (currentUserIsAdmin) { + return false; + } return ( currentUserId != null && currentUserId !== "" && diff --git a/src/domain/work-orders/utils/work-order-ids-filter.ts b/src/domain/work-orders/utils/work-order-ids-filter.ts new file mode 100644 index 00000000..fc60a7b3 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-ids-filter.ts @@ -0,0 +1,26 @@ +/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */ +export const WORK_ORDER_IDS_MAX = 200; + +/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */ +export function normalizeWorkOrderIds(values: Iterable): number[] { + const ids = new Set(); + for (const value of values) { + const text = typeof value === "number" ? String(value) : value.trim(); + if (!/^\d+$/.test(text)) continue; + const id = Number(text); + if (Number.isSafeInteger(id) && id > 0) ids.add(id); + if (ids.size === WORK_ORDER_IDS_MAX) break; + } + return [...ids]; +} + +/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */ +export function parseWorkOrderIdsParam(raw: string | null): number[] { + return raw ? normalizeWorkOrderIds(raw.split(",")) : []; +} + +/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */ +export function buildWorkOrderIdsHref(ids: number[]): string { + const normalized = normalizeWorkOrderIds(ids); + return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders"; +} diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 00d9d261..0dd7cd49 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,20 +1,38 @@ import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; -/** Backend RequestSizeLimit for media uploads and Extra Docs. */ -export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000; +/** + * Client-confirmed media contract (2026-09-22): photos up to 10 MB + * (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most + * 10 photos and 3 videos per work order. Applies to the Photos & Videos modal, + * the Completion Doc → Media tab and the vendor portal upload. Duration is + * pre-checked here and enforced by the server from the MP4/MOV header; + * unreadable metadata never blocks. Documents + * keep the 50 MB document cap; the signed completion PDF keeps its own cap. + */ +export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000; +export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000; +export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000; -/** Backend RequestSizeLimit for completion-document uploads. */ +/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */ export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000; +/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */ +export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES; + +export const MAX_VIDEO_DURATION_SECONDS = 90; +export const MAX_PHOTOS_PER_WORK_ORDER = 10; +export const MAX_VIDEOS_PER_WORK_ORDER = 3; + const MEDIA_MIME_TYPES = new Set([ "image/jpeg", "image/jpg", "image/png", + "image/heic", "video/mp4", "video/quicktime", ]); -const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); +const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]); const EXTRA_DOC_MIME_TYPES = new Set([ ...MEDIA_MIME_TYPES, @@ -25,6 +43,8 @@ const EXTRA_DOC_MIME_TYPES = new Set([ const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]); +export type WorkOrderUploadKind = "photo" | "video" | "document"; + function fileExtension(file: File): string { const name = file.name.toLowerCase(); const dot = name.lastIndexOf("."); @@ -37,11 +57,58 @@ function isTypeAllowed(file: File, mimes: Set, extensions: Set): return mimeOk || extOk; } -export function isWorkOrderFileTooLarge( - file: File, - maxBytes = MAX_WORK_ORDER_MEDIA_BYTES, -): boolean { - return file.size > maxBytes; +const KIND_BY_MIME: Record = { + "image/jpeg": "photo", + "image/jpg": "photo", + "image/png": "photo", + "image/heic": "photo", + "video/mp4": "video", + "video/quicktime": "video", +}; + +const KIND_BY_EXTENSION: Record = { + ".jpg": "photo", + ".jpeg": "photo", + ".png": "photo", + ".heic": "photo", + ".mp4": "video", + ".mov": "video", +}; + +/** + * Same rule as the server: an allowlisted browser type decides, otherwise the + * extension (mobile browsers often send an empty, octet-stream or foreign type). + */ +export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind { + return ( + KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document" + ); +} + +function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number { + if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES; + if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES; + return MAX_WORK_ORDER_DOCUMENT_BYTES; +} + +export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean { + const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file)); + return file.size > limit; +} + +/** Stable per-kind rejection message; never echoes file metadata. */ +export function workOrderFileSizeRejection(file: File): string | null { + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) { + return "Photos must be 10 MB or smaller."; + } + if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) { + return "Videos must be 100 MB or smaller."; + } + if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) { + return "Documents must be 50 MB or smaller."; + } + return null; } export function isWorkOrderMediaTypeAllowed(file: File): boolean { @@ -53,17 +120,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean { } export function workOrderMediaAcceptAttribute(): string { - return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; + return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"; } export function workOrderExtraDocsAcceptAttribute(): string { return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`; } -export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { +export function formatMediaSizeLimit(maxBytes: number): string { return `${Math.round(maxBytes / 1_000_000)} MB`; } +export type WorkOrderMediaKindCounts = { photos: number; videos: number }; + +/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */ +export function countWorkOrderMediaKinds( + uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>, +): WorkOrderMediaKindCounts { + return uploads.reduce( + (counts, item) => { + if (item.isLegacy || item.uploadStatus === "failed") return counts; + if (item.fileType === "image") counts.photos += 1; + if (item.fileType === "video") counts.videos += 1; + return counts; + }, + { photos: 0, videos: 0 }, + ); +} + /** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */ export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean { return isWorkOrderCompletionSurfaceReadOnly(status); diff --git a/src/domain/work-orders/utils/work-order-media-utils.ts b/src/domain/work-orders/utils/work-order-media-utils.ts index 4deee9a0..5c8c28e9 100644 --- a/src/domain/work-orders/utils/work-order-media-utils.ts +++ b/src/domain/work-orders/utils/work-order-media-utils.ts @@ -3,6 +3,7 @@ import type { MediaCategory, MediaFile, } from "@/domain/work-orders/types/work-order-media"; +import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits"; let mediaIdCounter = 0; @@ -16,6 +17,9 @@ export function classifyUpload(f: File): { if (t === "application/pdf" || /\.pdf$/i.test(f.name)) { return { fileType: "pdf", category: "Report" }; } + const kind = resolveWorkOrderUploadKind(f); + if (kind === "photo") return { fileType: "image", category: "Photo" }; + if (kind === "video") return { fileType: "video", category: "Video" }; return { fileType: "other", category: "Other" }; } diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index eb8a0664..35be49f4 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -10,6 +10,8 @@ export const queryKeys = { auth: { all: ["auth"] as const, session: () => [...queryKeys.auth.all, "session"] as const, + permissions: (userId: string | number) => + [...queryKeys.auth.all, "permissions", userId] as const, }, workOrders: { all: ["workOrders"] as const, @@ -49,6 +51,9 @@ export const queryKeys = { [...queryKeys.employees.all, "list", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const, }, + teamMemberInvite: { + details: (token: string) => ["teamMemberInvite", "details", token] as const, + }, teamMembers: { all: ["teamMembers"] as const, list: () => [...queryKeys.teamMembers.all, "list"] as const, @@ -81,6 +86,12 @@ export const queryKeys = { byWorkOrderType: (workOrderType: string) => [...queryKeys.services.all, "byWorkOrderType", workOrderType] as const, }, + completionTemplates: { + all: ["completionTemplates"] as const, + list: (search: string) => [...queryKeys.completionTemplates.all, "list", search] as const, + linkedWorkOrders: (id: number) => + [...queryKeys.completionTemplates.all, "linkedWorkOrders", id] as const, + }, locations: { all: ["locations"] as const, list: (params?: Record) => @@ -88,6 +99,8 @@ export const queryKeys = { detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const, dropdown: () => [...queryKeys.locations.all, "dropdown"] as const, sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const, + openWorkOrders: (id: string | number) => + [...queryKeys.locations.all, "open-work-orders", id] as const, }, vendors: { all: ["vendors"] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 035e91c0..e67c7bbf 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde const roles = userRoles.split(",").map((role) => role.trim().toLowerCase()); return roles.includes("admin") || roles.includes("scheduler"); } + +/** + * Site deletion is limited to Admin and Scheduler (the server enforces the DeleteSites team + * permission; this only hides the control from roles that would get a 403). + */ +export function canDeleteSites(userRoles: string | null | undefined): boolean { + return hasUserRole(userRoles, "admin") || hasUserRole(userRoles, "scheduler"); +} diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts new file mode 100644 index 00000000..f4e019fe --- /dev/null +++ b/src/lib/probe-video-duration.ts @@ -0,0 +1,75 @@ +/** + * Best-effort video duration probe for MP4/MOV. Reads the movie header + * (`moov` → `mvhd`) the same way the server does, seeking over box headers so a + * phone file with `moov` after a large `mdat` costs only a few small reads. + * Resolves null when the header cannot be read — callers must not block the + * upload in that case (the server enforces the limit from the same + * header and also lets unreadable metadata through). + */ +const MAX_BOXES_PER_LEVEL = 1024; + +type Box = { bodyStart: number; end: number }; + +async function readView(file: Blob, start: number, length: number): Promise { + if (start < 0 || length <= 0 || start + length > file.size) return null; + const buffer = await file.slice(start, start + length).arrayBuffer(); + return buffer.byteLength === length ? new DataView(buffer) : null; +} + +function boxType(view: DataView): string { + return String.fromCharCode( + view.getUint8(4), + view.getUint8(5), + view.getUint8(6), + view.getUint8(7), + ); +} + +async function findBox(file: Blob, start: number, end: number, type: string): Promise { + let offset = start; + for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) { + const header = await readView(file, offset, Math.min(16, end - offset)); + if (!header) return null; + let size = header.getUint32(0); + let headerLength = 8; + if (size === 1) { + if (header.byteLength < 16) return null; + const largeSize = header.getBigUint64(8); + if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; + size = Number(largeSize); + headerLength = 16; + } else if (size === 0) { + size = end - offset; + } + if (size < headerLength || offset + size > end) return null; + if (boxType(header) === type) { + return { bodyStart: offset + headerLength, end: offset + size }; + } + offset += size; + } + return null; +} + +async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise { + const version = await readView(file, mvhd.bodyStart, 1); + if (!version) return null; + const isVersion1 = version.getUint8(0) === 1; + const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20); + if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null; + const timescale = fields.getUint32(isVersion1 ? 20 : 12); + const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16)); + const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn; + if (timescale === 0 || duration === unknown) return null; + return Number(duration) / timescale; +} + +export async function probeVideoDurationSeconds(file: Blob): Promise { + try { + const moov = await findBox(file, 0, file.size, "moov"); + if (!moov) return null; + const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd"); + return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null; + } catch { + return null; + } +} diff --git a/src/test/api/api-error.test.ts b/src/test/api/api-error.test.ts index 71c3d1c9..af435ad9 100644 --- a/src/test/api/api-error.test.ts +++ b/src/test/api/api-error.test.ts @@ -1,6 +1,6 @@ import ky from "ky"; import { describe, expect, it, vi } from "vitest"; -import { normalizeApiRequestError } from "@/api/api-error"; +import { normalizeApiRequestError, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; const RESOURCE_URL = "https://example.test/api/resource"; @@ -29,6 +29,32 @@ describe("normalizeApiRequestError", () => { ); }); + it("preserves only the allowlisted public denial for 403 responses", async () => { + const allowedFetch = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ message: REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const unrelatedFetch = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ message: "accountId 42 is forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + + await expect(createClient(allowedFetch).get(RESOURCE_URL).json()).rejects.toThrow( + REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE, + ); + await expect(createClient(unrelatedFetch).get(RESOURCE_URL).json()).rejects.toThrow( + "You do not have permission to perform this action.", + ); + }); + it("reports a connection failure for network errors", async () => { const fetchImpl = vi.fn(() => Promise.reject(new TypeError("fetch failed")), diff --git a/src/test/app/(protected)/account/my-account-page.test.tsx b/src/test/app/(protected)/account/my-account-page.test.tsx new file mode 100644 index 00000000..65c5ad3e --- /dev/null +++ b/src/test/app/(protected)/account/my-account-page.test.tsx @@ -0,0 +1,144 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import MyAccountPage from "@/app/(protected)/account"; +import * as authApi from "@/domain/auth/api/auth-api"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { createAppQueryClient } from "@/lib/query/query-client"; +import { renderWithProviders } from "@/test/test-utils"; + +const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn() })); + +const SIGNED_IN_USER: AuthUser = { + token: "test-jwt-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "dana@seahavenind.com", + userRoles: "Dispatcher", + phoneNumber: "5551234567", + fullname: "Dana Reyes", + id: "user-1", +}; + +vi.mock("@/domain/auth/api/auth-api", () => ({ + getSession: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), +})); + +vi.mock("react-toastify", () => ({ + toast: { success: toastSuccessMock, error: vi.fn(), info: vi.fn() }, +})); + +/** Uses the app's query client, so the global mutation error toast is live. */ +function renderPage() { + return renderWithProviders(, { + route: "/account", + queryClient: createAppQueryClient(), + }); +} + +function field(label: RegExp) { + return screen.getByLabelText(label, { selector: "input" }); +} + +async function fillPasswords( + user: ReturnType, + { current, next, confirm }: { current: string; next: string; confirm: string }, +) { + await user.type(field(/^current password/i), current); + await user.type(field(/^new password/i), next); + await user.type(field(/^confirm new password/i), confirm); +} + +describe("My Account page", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(authApi.getSession).mockResolvedValue(SIGNED_IN_USER); + }); + + it("shows the signed-in user's name, role and email as read-only details", async () => { + renderPage(); + + const details = await screen.findByRole("region", { name: "Account details" }); + expect(within(details).getByText("Dana Reyes")).toBeInTheDocument(); + expect(within(details).getByText("Dispatcher")).toBeInTheDocument(); + expect(within(details).getByText("dana@seahavenind.com")).toBeInTheDocument(); + expect(within(details).queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("shows the live checklist for the new password", async () => { + const user = userEvent.setup(); + renderPage(); + + const checklist = screen.getByRole("list", { name: "Password requirements" }); + expect(within(checklist).getAllByText("requirement not met")).toHaveLength(4); + + await user.type(field(/^new password/i), "Abc1!x"); + + expect(within(checklist).getAllByText("requirement met")).toHaveLength(4); + }); + + it("keeps Update password disabled until every rule is met and the confirmation matches", async () => { + const user = userEvent.setup(); + renderPage(); + const submit = screen.getByRole("button", { name: "Update password" }); + + await fillPasswords(user, { current: "Current1!", next: "abcdef", confirm: "abcdef" }); + expect(submit).toBeDisabled(); + + await user.clear(field(/^new password/i)); + await user.type(field(/^new password/i), "Abc1!x"); + expect(submit).toBeDisabled(); + + await user.clear(field(/^confirm new password/i)); + await user.type(field(/^confirm new password/i), "Abc1!x"); + expect(submit).toBeEnabled(); + expect(authApi.changePassword).not.toHaveBeenCalled(); + }); + + it("tells the user when the confirmation does not match", async () => { + const user = userEvent.setup(); + renderPage(); + + await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!y" }); + await user.tab(); + + expect(await screen.findByText("Passwords don't match")).toBeInTheDocument(); + }); + + it("sends the current and new password and clears the form after a successful change", async () => { + vi.mocked(authApi.changePassword).mockResolvedValue({ status: "Success " }); + const user = userEvent.setup(); + renderPage(); + + await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!x" }); + await user.click(screen.getByRole("button", { name: "Update password" })); + + await waitFor(() => + expect(authApi.changePassword).toHaveBeenCalledWith({ + Currentpassword: "Current1!", + Newpassword: "Abc1!x", + Confirmpassword: "Abc1!x", + }), + ); + await waitFor(() => expect(field(/^current password/i)).toHaveValue("")); + expect(toastSuccessMock).toHaveBeenCalledWith("Password updated"); + }); + + it("shows the server's current-password rejection on the current password field", async () => { + vi.mocked(authApi.changePassword).mockRejectedValue(new Error("Current password is incorrect")); + const user = userEvent.setup(); + renderPage(); + + await fillPasswords(user, { current: "Wrong1!", next: "Abc1!x", confirm: "Abc1!x" }); + await user.click(screen.getByRole("button", { name: "Update password" })); + + expect(await screen.findByText("Current password is incorrect")).toBeInTheDocument(); + expect(field(/^current password/i)).toHaveAttribute("aria-invalid", "true"); + expect(toastSuccessMock).not.toHaveBeenCalled(); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts new file mode 100644 index 00000000..94f4ac7f --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts @@ -0,0 +1,116 @@ +import type { Mock } from "vitest"; +import type { CompletionTemplateDto } from "@/domain/completion-templates/schemas/completion-template-schema"; + +export interface ApiMocks { + apiGet: Mock; + apiPost: Mock; + apiPut: Mock; + apiDeleteNoContent: Mock; +} + +interface WriteBody { + name: string; + extraSafetyNote: string | null; + procedures: { name: string; description: string }[]; +} + +export function templateDto(overrides: Partial = {}): CompletionTemplateDto { + return { + id: 12, + name: "Backflow Test Report", + extraSafetyNote: null, + procedures: [], + createdAt: "2026-09-01T13:00:00Z", + updatedAt: null, + createdByName: "Jane Doe", + serviceKey: "backflow", + workOrderType: 2, + templateUrl: "https://files.example/backflow.pdf", + isActive: true, + ...overrides, + }; +} + +export const BACKFLOW = templateDto({ + id: 12, + name: "Backflow Test Report", + extraSafetyNote: "Wear gloves", + procedures: [ + { name: "Shut-off", description: "Close valve", sortOrder: 1 }, + { name: "Backflow Procedure", description: "line 1\nline 2", sortOrder: 0 }, + ], + updatedAt: "2026-09-25T14:00:00Z", +}); + +export const SCRUBBER = templateDto({ + id: 13, + name: "Scrubber Pit Augering", + createdByName: null, + createdAt: "2026-06-01T10:00:00Z", +}); + +function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) { + return templateDto({ + ...base, + id, + name: body.name, + extraSafetyNote: body.extraSafetyNote, + procedures: body.procedures.map((procedure, index) => ({ ...procedure, sortOrder: index })), + updatedAt: "2026-09-25T15:00:00Z", + }); +} + +export const MY_PERMISSIONS_URL = "team-members/me/permissions"; + +export const TEMPLATE_PERMISSIONS = [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", +]; + +/** What the effective-permissions endpoint does: answer with keys, fail, or never settle. */ +export type PermissionsAnswer = string[] | Error | "pending"; + +export const permissionsAnswer: { current: PermissionsAnswer } = { + current: TEMPLATE_PERMISSIONS, +}; + +function answerPermissions(): Promise { + const answer = permissionsAnswer.current; + if (answer === "pending") return new Promise(() => undefined); + if (answer instanceof Error) return Promise.reject(answer); + return Promise.resolve({ permissions: answer }); +} + +/** + * Answers the template endpoints the way the backend contract describes, and the signed-in + * user's effective permissions with `permissionsAnswer.current`. + */ +export function serveTemplates( + api: ApiMocks, + templates: CompletionTemplateDto[], + linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] }, +) { + api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => { + if (url === MY_PERMISSIONS_URL) return answerPermissions(); + if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked); + const term = options?.searchParams?.search?.toLowerCase() ?? ""; + return Promise.resolve( + templates.filter((template) => (template.name ?? "").toLowerCase().includes(term)), + ); + }); + api.apiPost.mockImplementation((_url: string, body: WriteBody) => + Promise.resolve(savedDto(99, body)), + ); + api.apiPut.mockImplementation((url: string, body: WriteBody) => { + const id = Number(url.split("/").pop()); + return Promise.resolve( + savedDto( + id, + body, + templates.find((template) => template.id === id), + ), + ); + }); + api.apiDeleteNoContent.mockResolvedValue(undefined); +} diff --git a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx new file mode 100644 index 00000000..39261999 --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -0,0 +1,294 @@ +import { focusManager, QueryClient } from "@tanstack/react-query"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; +import { renderWithProviders } from "@/test/test-utils"; +import { + BACKFLOW, + MY_PERMISSIONS_URL, + permissionsAnswer, + SCRUBBER, + serveTemplates, + TEMPLATE_PERMISSIONS, + templateDto, +} from "./completion-docs-fixtures"; + +const api = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDeleteNoContent: vi.fn(), +})); +const auth = vi.hoisted(() => ({ id: "u1", userRoles: "Admin" })); + +vi.mock("@/api/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: auth.id, userRoles: auth.userRoles } }), +})); + +let user = userEvent.setup({ delay: null }); + +function renderPage() { + return renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + }); +} + +async function openTemplate(name: string) { + await user.click(await screen.findByRole("row", { name: new RegExp(name) })); + return screen.findByRole("dialog"); +} + +/** Lets the effective-permissions request finish (or stay pending) before asserting absence. */ +async function permissionsRequested() { + await waitFor(() => expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL)); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); +} + +beforeEach(() => { + user = userEvent.setup({ delay: null }); + auth.id = "u1"; + auth.userRoles = "Admin"; + permissionsAnswer.current = TEMPLATE_PERMISSIONS; + serveTemplates(api, [BACKFLOW, SCRUBBER]); +}); + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +describe("Completion Docs page", { timeout: 15_000 }, () => { + it("lists every template with its Document Title, Last Updated and Created By", async () => { + renderPage(); + + const backflow = await screen.findByRole("row", { name: /Backflow Test Report/ }); + const table = screen.getByRole("table", { name: "Completion document templates" }); + const headers = within(table) + .getAllByRole("columnheader") + .map((cell) => cell.textContent); + expect(headers.slice(0, 3)).toEqual(["Document Title", "Last Updated", "Created By"]); + + expect(within(backflow).getByText("Sep 25, 2026")).toBeInTheDocument(); + expect(within(backflow).getByText("Jane Doe")).toBeInTheDocument(); + const scrubber = within(table).getByRole("row", { name: /Scrubber Pit Augering/ }); + expect(within(scrubber).getByText("Jun 1, 2026")).toBeInTheDocument(); + expect(within(scrubber).getByText("—")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Completion Docs" })).toBeInTheDocument(); + expect(screen.getAllByText("2 templates")).toHaveLength(2); + }); + + it("filters by Document Title as the user types and explains an empty result", async () => { + renderPage(); + await screen.findByRole("row", { name: /Scrubber Pit Augering/ }); + const search = screen.getByRole("searchbox", { name: "Search document title" }); + + await user.type(search, "backf"); + expect( + await screen.findByText("1 template", { selector: "span" }, { timeout: 2000 }), + ).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Scrubber Pit Augering/ })).not.toBeInTheDocument(); + expect(api.apiGet).toHaveBeenCalledWith("workorders/completion-templates", { + searchParams: { search: "backf" }, + }); + + await user.clear(search); + await user.type(search, "no such document"); + expect( + await screen.findByText("No templates found", {}, { timeout: 2000 }), + ).toBeInTheDocument(); + expect(screen.getByText("Try a different search, or add a new template.")).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument(); + }); + + it("reports a failed load without the empty-search message, including after a failed Retry", async () => { + api.apiGet.mockRejectedValue(new Error("Templates are unavailable")); + renderPage(); + + const retry = await screen.findByRole("button", { name: "Retry" }); + expect(screen.getByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + + const callsBeforeRetry = api.apiGet.mock.calls.length; + await user.click(retry); + await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry)); + expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + }); + + it("opens a read-only detail panel from a row with every part of the template", async () => { + renderPage(); + const dialog = await openTemplate("Backflow Test Report"); + + expect(within(dialog).getByText("Document preview")).toBeInTheDocument(); + for (const field of ["WO#", "Site Code", "Address", "Service Type"]) { + expect(within(dialog).getByText(field)).toBeInTheDocument(); + } + const safety = within(dialog).getByRole("list", { name: "Safety notes" }); + expect(within(safety).getAllByRole("listitem")).toHaveLength(4); + expect(within(safety).getByText("Wear gloves")).toBeInTheDocument(); + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + expect( + within(dialog).getByText("Created by Jane Doe · Updated Sep 25, 2026"), + ).toBeInTheDocument(); + }); + + it("shows that a template without procedures has none defined yet", async () => { + renderPage(); + const dialog = await openTemplate("Scrubber Pit Augering"); + + expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument(); + expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument(); + }); +}); + +describe( + "Completion Docs actions follow the server's effective permissions", + { timeout: 15_000 }, + () => { + async function expectEveryAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + } + + async function expectNoAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + } + + it("gives every template action to a user the server grants all three", async () => { + renderPage(); + + await expectEveryAction(); + expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL); + }); + + it("shows every action to a Dispatcher whose overrides grant them", async () => { + auth.userRoles = "Dispatcher"; + renderPage(); + + await expectEveryAction(); + }); + + it("hides Delete when the effective permissions omit it, even for an Admin", async () => { + permissionsAnswer.current = ["createCompletionDocTemplates", "editCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); + + it("hides Edit from a Scheduler whose edit permission was revoked", async () => { + auth.userRoles = "Scheduler"; + permissionsAnswer.current = ["createCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); + + it("hides add, edit and delete entirely from a user without those permissions", async () => { + auth.userRoles = "Dispatcher"; + permissionsAnswer.current = ["createWorkOrders"]; + serveTemplates(api, [templateDto()]); + renderPage(); + + await expectNoAction(); + }); + + it("hides every action while the permissions are still loading", async () => { + permissionsAnswer.current = "pending"; + renderPage(); + + await expectNoAction(); + }); + + it("hides every action when the permissions fail to load, and still lists templates", async () => { + permissionsAnswer.current = new Error("Network down"); + renderPage(); + + expect(await screen.findByRole("row", { name: /Scrubber Pit Augering/ })).toBeInTheDocument(); + await expectNoAction(); + }); + + it("hides the actions again when a later permissions refresh fails", async () => { + renderPage(); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + + permissionsAnswer.current = new Error("Network down"); + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + await waitFor(() => + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(), + ); + }); + + it("picks up a changed override when the window regains focus", async () => { + permissionsAnswer.current = []; + renderPage(); + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + + permissionsAnswer.current = ["createCompletionDocTemplates"]; + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + }); + + it("never reuses one signed-in user's permissions for the next", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const first = renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + first.unmount(); + + auth.id = "u2"; + permissionsAnswer.current = "pending"; + renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + }); + }, +); diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx new file mode 100644 index 00000000..d8448e7d --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -0,0 +1,307 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HTTPError } from "ky"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; +import { renderWithProviders } from "@/test/test-utils"; +import { BACKFLOW, SCRUBBER, serveTemplates } from "./completion-docs-fixtures"; + +const api = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDeleteNoContent: vi.fn(), +})); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +const navigate = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); +vi.mock("react-toastify", () => ({ toast })); +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }), +})); + +let user = userEvent.setup({ delay: null }); + +function renderPage() { + return renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + }); +} + +async function openNewTemplate() { + renderPage(); + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await user.click(await screen.findByRole("button", { name: /Add Template/ })); + return screen.findByRole("dialog"); +} + +async function openTemplate(name: string) { + renderPage(); + await user.click(await screen.findByRole("row", { name: new RegExp(name) })); + return screen.findByRole("dialog"); +} + +function previewHeadings(dialog: HTMLElement) { + const preview = within(dialog).getByTestId("completion-template-preview"); + return within(preview) + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent); +} + +/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */ +function unprocessable(body: { code: string; message: string }, message = body.message) { + const error = new HTTPError( + new Response(null, { status: 422 }), + new Request("http://api/workorders/completion-templates"), + {} as never, + ); + error.data = body; + error.message = message; + return error; +} + +async function addProcedure(dialog: HTMLElement, index: number, name: string) { + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + await user.type(within(dialog).getByLabelText(`Procedure ${index} name`), name); +} + +beforeEach(() => { + user = userEvent.setup({ delay: null }); + vi.clearAllMocks(); + serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] }); +}); + +describe("Add Template", { timeout: 15_000 }, () => { + it("blocks saving without a Document Title and says why", async () => { + const dialog = await openNewTemplate(); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveFocus(); + expect(api.apiPost).not.toHaveBeenCalled(); + }); + + it("shows the fixed fields read-only and lets the author add then remove one safety note", async () => { + const dialog = await openNewTemplate(); + + const safety = within(dialog).getByRole("list", { name: "Safety notes" }); + expect(within(safety).getAllByRole("listitem")).toHaveLength(3); + expect(within(dialog).getByText("WO#")).toBeInTheDocument(); + expect(within(dialog).getAllByRole("textbox")).toHaveLength(1); + + await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" })); + expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull(); + await user.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit"); + expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent( + "Mind the pit", + ); + + await user.click(within(dialog).getByRole("button", { name: "Remove safety note" })); + expect(within(dialog).queryByLabelText("Extra safety note")).toBeNull(); + expect(within(dialog).getByTestId("completion-template-preview")).not.toHaveTextContent( + "Mind the pit", + ); + }); + + it("adds and removes procedures and previews them in editor order", async () => { + const dialog = await openNewTemplate(); + expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument(); + + await addProcedure(dialog, 1, "Isolate"); + await addProcedure(dialog, 2, "Inspect"); + await addProcedure(dialog, 3, "Restore"); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Inspect", "Restore"]); + + await user.click(within(dialog).getByRole("button", { name: "Remove procedure 2" })); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore"]); + expect(within(dialog).getByLabelText("Procedure 2 name")).toHaveValue("Restore"); + + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore", "Procedure 3"]); + }); + + it("saves the title, note and procedures in order, then confirms with a toast", async () => { + const dialog = await openNewTemplate(); + await user.type( + within(dialog).getByRole("textbox", { name: /Document Title/ }), + " PRV Check ", + ); + await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" })); + await user.type(within(dialog).getByLabelText("Extra safety note"), "Gloves"); + await addProcedure(dialog, 1, "Open"); + await user.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b"); + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + await addProcedure(dialog, 3, "Close"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template added")); + expect(api.apiPost).toHaveBeenCalledWith("workorders/completion-templates", { + name: "PRV Check", + extraSafetyNote: "Gloves", + procedures: [ + { name: "Open", description: "a\nb" }, + { name: "Close", description: "" }, + ], + }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("shows the server's blank-title rejection on the title field", async () => { + api.apiPost.mockRejectedValue( + unprocessable({ code: "TitleRequired", message: "Document Title is required." }), + ); + const dialog = await openNewTemplate(); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("reports any other rejected value in a toast and keeps the form open", async () => { + const message = "Procedure name must be 200 characters or fewer."; + api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message)); + const dialog = await openNewTemplate(); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect(within(dialog).queryByText("Document Title is required")).toBeNull(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); +}); + +describe("Edit template", { timeout: 15_000 }, () => { + it("Cancel discards unsaved changes and returns to the original read-only values", async () => { + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + expect(within(dialog).getByText("Live preview")).toBeInTheDocument(); + const title = within(dialog).getByRole("textbox", { name: /Document Title/ }); + await user.clear(title); + await user.type(title, "Renamed"); + await user.click(within(dialog).getByRole("button", { name: "Remove procedure 1" })); + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + expect(within(dialog).getAllByText("Backflow Test Report").length).toBeGreaterThan(0); + expect(within(dialog).queryByText("Renamed")).not.toBeInTheDocument(); + expect(previewHeadings(dialog)).toEqual(["Backflow Procedure", "Shut-off"]); + expect(api.apiPut).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveValue( + "Backflow Test Report", + ); + }); + + it("saves an edit, keeps the legacy fields and confirms with a toast", async () => { + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2"); + + await user.click(within(dialog).getByRole("button", { name: /Save changes/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated")); + expect(api.apiPut).toHaveBeenCalledWith("workorders/completion-templates/12", { + serviceKey: "backflow", + workOrderType: 2, + templateUrl: "https://files.example/backflow.pdf", + isActive: true, + name: "Backflow Test Report v2", + extraSafetyNote: "Wear gloves", + procedures: [ + { name: "Backflow Procedure", description: "line 1\nline 2" }, + { name: "Shut-off", description: "Close valve" }, + ], + }); + expect( + await within(dialog).findByText("Created by Jane Doe · Updated Sep 25, 2026"), + ).toBeInTheDocument(); + }); + + it.each([ + [ + "Escape", + async () => { + screen.getByRole("textbox", { name: /Document Title/ }).focus(); + await user.keyboard("{Escape}"); + }, + ], + ["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))], + ])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => { + let finishSave: () => void = () => {}; + const respond = api.apiPut.getMockImplementation()!; + api.apiPut.mockImplementation( + (...args: unknown[]) => + new Promise((resolve) => { + finishSave = () => resolve(respond(...args)); + }), + ); + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2"); + await user.click(within(dialog).getByRole("button", { name: /Save changes/ })); + await waitFor(() => expect(api.apiPut).toHaveBeenCalled()); + + await dismiss(); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + finishSave(); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); + +describe("Delete template", { timeout: 15_000 }, () => { + it("states how many open work orders depend on it and links to exactly those", async () => { + serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 2, workOrderIds: [101, 205] }); + const dialog = await openTemplate("Backflow Test Report"); + + await user.click(within(dialog).getByRole("button", { name: /Delete/ })); + + const confirm = await screen.findByRole("dialog", { name: "Delete this template?" }); + expect( + await within(confirm).findByText( + /It's still linked to 2 open work orders through its Service/, + ), + ).toBeInTheDocument(); + expect(api.apiDeleteNoContent).not.toHaveBeenCalled(); + await user.click(within(confirm).getByRole("button", { name: "View linked work orders" })); + expect(navigate).toHaveBeenCalledWith("/workorders?ids=101,205"); + }); + + it("still asks first when nothing depends on it, then deletes with a toast", async () => { + const dialog = await openTemplate("Scrubber Pit Augering"); + + await user.click(within(dialog).getByRole("button", { name: /Delete/ })); + + const confirm = await screen.findByRole("dialog", { name: "Delete this template?" }); + expect( + await within(confirm).findByText( + 'This will permanently remove "Scrubber Pit Augering". This cannot be undone.', + ), + ).toBeInTheDocument(); + expect(within(confirm).queryByRole("button", { name: "View linked work orders" })).toBeNull(); + + await user.click(within(confirm).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template deleted")); + expect(api.apiDeleteNoContent).toHaveBeenCalledWith("workorders/completion-templates/13"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); +}); diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index b219e880..27672d72 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -63,6 +63,7 @@ const statsData = { scheduledTomorrow: 3, pendingUplifts: 5, avetaPending: 0, + unassigned: 4, breakdown: [ { status: "PM", count: 7 }, { status: "Completed", count: 0 }, @@ -88,6 +89,11 @@ function renderDashboard() { return renderWithProviders(); } +function lastNavigation(): string { + const calls = navigate.mock.calls; + return String(calls[calls.length - 1]?.[0]); +} + describe("DashboardPage", () => { beforeEach(() => { authUser.current = { id: "99", userRoles: "Admin" }; @@ -120,6 +126,7 @@ describe("DashboardPage", () => { "Scheduled Tomorrow", "Pending Uplifts", "Aveta Pending", + "Unassigned", "Work Order Breakdown", "Status Distribution", "Dispatcher Workload", @@ -257,6 +264,29 @@ describe("DashboardPage", () => { expect(lastRequestedPage).toBe(1); }); + it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => { + renderDashboard(); + + const card = screen.getByRole("button", { name: /Unassigned/ }); + expect(card).toHaveTextContent("4"); + fireEvent.click(card); + + const weekUrl = new URL(lastNavigation(), "http://x"); + expect(weekUrl.pathname).toBe("/workorders"); + expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned"); + expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed"); + expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + fireEvent.click(screen.getByRole("button", { name: "All time" })); + fireEvent.click(screen.getByRole("button", { name: /Unassigned/ })); + + const allTimeUrl = new URL(lastNavigation(), "http://x"); + expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned"); + expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false); + expect(allTimeUrl.searchParams.has("dateTo")).toBe(false); + }); + it("keeps drilldown dates deterministic on the business clock", () => { const today = businessTodayIso(); diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx new file mode 100644 index 00000000..a9b6c328 --- /dev/null +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HTTPError } from "ky"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { formatStateOptionLabel, type StateCode } from "@/data/areas"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const create = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { create: (...args: unknown[]) => create(...args) }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [{ id: 7, name: "Acme Logistics" }], totalCount: 1, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +function duplicateCodeError(): HTTPError { + const body = { + status: "Conflict", + message: "This site code already exists.", + code: "DuplicateSiteCode", + }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 409 }), + new Request("https://api.example.test/api/locations"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function renderDialog() { + const onClose = vi.fn(); + renderWithProviders(, { withAuth: false }); + return { onClose, dialog: screen.getByRole("dialog", { name: /add site/i }) }; +} + +async function pickOption(label: string, optionName: string, search: string) { + const input = screen.getByRole("combobox", { name: label }); + input.focus(); + fireEvent.mouseDown(input); + fireEvent.change(input, { target: { value: search } }); + fireEvent.click(await screen.findByRole("option", { name: optionName })); +} + +async function fillRequiredFields(dialog: HTMLElement) { + fireEvent.change(within(dialog).getByRole("textbox", { name: "Site Code" }), { + target: { value: "dal1" }, + }); + await pickOption("Client", "Acme Logistics", "Acme"); + fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + await pickOption("State", "Texas (TX)", "texas"); + fireEvent.change(within(dialog).getByLabelText(/^Contact Name \(Main\)/), { + target: { value: "Jane" }, + }); + fireEvent.change(within(dialog).getByLabelText(/^Phone/), { + target: { value: "(421) 433-0032" }, + }); +} + +describe("AddSiteDialog", () => { + beforeEach(() => { + create.mockReset(); + }); + + it("blocks submission and names every missing required field", async () => { + const { dialog } = renderDialog(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + for (const message of [ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]) { + expect(await within(dialog).findByText(message)).toBeInTheDocument(); + } + expect(create).not.toHaveBeenCalled(); + }); + + it("shows the map placeholder until Street, City and State are filled, then the preview", async () => { + const { dialog } = renderDialog(); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + await fillRequiredFields(dialog); + + const map = within(dialog).getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX&output=embed", + ); + expect(within(dialog).getByText("Area: West")).toBeInTheDocument(); + }); + + it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => { + const user = userEvent.setup(); + const { dialog } = renderDialog(); + const street = within(dialog).getByRole("combobox", { name: "Street Address" }); + + await user.type(street, "38"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + await user.type(street, "1"); + const picked = suggestAddresses("381")[0]; + if (picked === undefined) throw new Error("expected a suggestion for 3 typed characters"); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getByRole("option", { name: picked.label })); + + await waitFor(() => expect(street).toHaveValue("381")); + expect(within(dialog).getByRole("textbox", { name: "City" })).toHaveValue(picked.city); + expect(within(dialog).getByRole("combobox", { name: "State" })).toHaveValue( + formatStateOptionLabel(picked.state as StateCode), + ); + expect(within(dialog).getByTitle("Site location map")).toBeInTheDocument(); + }); + + it("keeps typed Street Address text when no suggestion is picked", async () => { + create.mockResolvedValue({}); + const user = userEvent.setup(); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + const street = within(dialog).getByRole("combobox", { name: "Street Address" }); + await user.clear(street); + await user.type(street, "12 Custom Ln"); + await user.keyboard("{Escape}"); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ address: "12 Custom Ln", city: "Dallas", state: "TX" }), + ); + }); + + it("creates the site with an upper-cased code, optional fields blank, then closes", async () => { + create.mockResolvedValue({}); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "DAL1", + accountId: "7", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + sitePhone: "", + contactEmail: "", + status: "Active", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }), + ); + }); + + it("keeps the modal open with an inline error when the Site Code already exists", async () => { + create.mockRejectedValue(duplicateCodeError()); + const toastError = vi.spyOn(toast, "error"); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + expect(await within(dialog).findByText("This site code already exists.")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: "Site Code" })).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(onClose).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("Cancel closes without calling the API", async () => { + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx index 60444e29..fa6df17e 100644 --- a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -37,6 +37,7 @@ function ContactsHarness({ state: "TX", zipCode: "70068", contactEmail: "jane@example.com", + accountId: "7", contacts: initialContacts, }, }); @@ -78,7 +79,7 @@ describe("LocationContactsFieldArray", () => { { withAuth: false }, ); - fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + fireEvent.click(screen.getByRole("button", { name: /add point of contact/i })); expect(nameInputs()).toHaveLength(2); expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx deleted file mode 100644 index 23b716a3..00000000 --- a/src/test/app/(protected)/locations/location-form-state.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { screen } from "@testing-library/react"; -import { Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; -import LocationFormPage from "@/app/(protected)/locations/_components/location-form"; -import { renderWithProviders } from "@/test/test-utils"; - -const useLocationDetail = vi.fn(); -const useAccountsList = vi.fn(); -const useCreateLocation = vi.fn(); -const useUpdateLocation = vi.fn(); - -vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ - useLocationDetail: (...args: unknown[]) => useLocationDetail(...args), -})); - -vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ - useAccountsList: (...args: unknown[]) => useAccountsList(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-create-location", () => ({ - useCreateLocation: (...args: unknown[]) => useCreateLocation(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-update-location", () => ({ - useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args), -})); - -function mockHooks(location: Record | null) { - useLocationDetail.mockReturnValue({ - data: location, - isLoading: false, - error: null, - }); - useAccountsList.mockReturnValue({ - data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, - isLoading: false, - }); - useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); - useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); -} - -function renderEditPage(locationId: string) { - return renderWithProviders( - - } /> - , - { - route: `/locations/edit/${locationId}`, - routerProps: { initialEntries: [`/locations/edit/${locationId}`] }, - withAuth: false, - }, - ); -} - -describe("LocationFormPage state field", () => { - it("normalizes legacy API state values for edit", () => { - mockHooks({ - id: "1", - name: "Dallas DC", - address: "3811 Distribution Dr", - city: "Dallas", - state: "texas", - zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", - contactEmail: "jane@example.com", - status: "Active", - accountId: "7", - }); - - renderEditPage("1"); - - const stateInput = screen.getByLabelText(/^State/); - expect(stateInput).toHaveValue("Texas (TX)"); - expect(screen.getByText("Area: West")).toBeInTheDocument(); - }); - - it("normalizes code values and keeps their derived area", () => { - mockHooks({ - id: "2", - state: "IL", - status: "Active", - }); - - renderEditPage("2"); - - expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)"); - expect(screen.getByText("Area: Central")).toBeInTheDocument(); - }); - - it("starts empty in create mode with no implicit IL default", () => { - mockHooks(null); - - renderWithProviders(, { - route: "/locations/new", - routerProps: { initialEntries: ["/locations/new"] }, - withAuth: false, - }); - - expect(screen.getByLabelText(/^State/)).toHaveValue(""); - expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); - }); -}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx index e0258626..a903447f 100644 --- a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial): LocationListItem return { id: 1, name: "Dallas DC", + clientName: "Acme", + sitePhone: "", address: "3811 Distribution Dr", city: "Dallas", state: "TX", diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx index fb45e28c..19b54ad8 100644 --- a/src/test/app/(protected)/locations/locations-list-area.test.tsx +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -125,7 +125,7 @@ describe("LocationsListPage area wiring", () => { fireEvent.mouseDown(screen.getByLabelText("Area")); fireEvent.click(screen.getByRole("option", { name: "West" })); - fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + fireEvent.change(screen.getByPlaceholderText("Search sites..."), { target: { value: "dal" }, }); diff --git a/src/test/app/(protected)/locations/site-delete.test.tsx b/src/test/app/(protected)/locations/site-delete.test.tsx new file mode 100644 index 00000000..10b2c1d1 --- /dev/null +++ b/src/test/app/(protected)/locations/site-delete.test.tsx @@ -0,0 +1,286 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { HTTPError } from "ky"; +import { Route, Routes, useLocation } from "react-router"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import { normalizeApiRequestError } from "@/api/api-error"; +import type { Location, LocationListItem } from "@/domain/locations/types/location"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; +import { renderWithProviders } from "@/test/test-utils"; + +const getList = vi.fn(); +const getById = vi.fn(); +const getOpenWorkOrders = vi.fn(); +const deleteSite = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args), + delete: (...args: unknown[]) => deleteSite(...args), + update: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const site: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }], +}; + +const row: LocationListItem = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + sitePhone: "", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "", + status: "Active", + accountId: 7, + contacts: site.contacts, +}; + +function WorkOrdersProbe() { + const location = useLocation(); + return
    {`${location.pathname}${location.search}`}
    ; +} + +function renderAs(userRoles: string) { + const auth = { user: { userRoles } } as unknown as AuthContextValue; + return renderWithProviders( + + + } /> + } /> + + , + { withAuth: false, route: "/locations" }, + ); +} + +async function openPanel(): Promise { + fireEvent.click(await screen.findByRole("button", { name: "DAL1" })); + const panel = screen.getByRole("region", { name: "Site details" }); + await within(panel).findByRole("button", { name: "Edit" }); + return panel; +} + +function forbidden(): HTTPError { + const body = { status: "Error", message: "You are not allowed to delete sites." }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 403 }), + new Request("https://api.example.test/api/locations/1"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +describe("Delete a site from the detail panel", () => { + beforeEach(() => { + for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) { + mock.mockReset(); + } + getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 }); + getById.mockResolvedValue(site); + }); + + it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => { + renderAs(role); + const panel = await openPanel(); + + expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); + + it("hides Delete from a Dispatcher and removes the old row delete control", async () => { + renderAs("Dispatcher"); + const panel = await openPanel(); + + expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument(); + }); + + it("warns with the open work-order count and links to exactly those work orders", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] }); + renderAs("Scheduler"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect( + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.", + ), + ).toBeInTheDocument(); + expect(getOpenWorkOrders).toHaveBeenCalledWith("1"); + + fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" })); + + expect(await screen.findByTestId("work-orders-url")).toHaveTextContent( + "/workorders?ids=101,205,318", + ); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it.each([ + ["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }], + ["an open count with no ids", { count: 4, workOrderIds: [] }], + ])("links to the site's open work-order board when there are %s", async (_label, result) => { + getOpenWorkOrders.mockResolvedValue(result); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`)); + + fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" })); + + const url = new URL( + (await screen.findByTestId("work-orders-url")).textContent ?? "", + "https://app.test", + ); + expect(url.pathname).toBe("/workorders"); + expect(url.searchParams.get("ids")).toBeNull(); + expect(url.searchParams.get("sites")).toBe("1"); + expect(url.searchParams.get("statuses")?.split(",")).toEqual([ + "Incomplete", + "Pending", + "Scheduled", + "En Route", + "On Site", + "In Progress", + "Rescheduled", + "Pending Quote", + ]); + }); + + it("confirms without a warning when nothing is open, deletes and closes the panel", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] }); + deleteSite.mockResolvedValue(undefined); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect( + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. This cannot be undone.", + ), + ).toBeInTheDocument(); + expect( + within(dialog).queryByRole("button", { name: "View open work orders" }), + ).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1")); + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); + + it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => { + getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error")); + getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] }); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Couldn't check this site for open work orders. Try again before deleting it.", + ); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Try again" })); + + expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled(); + expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled(); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it("holds Delete on reopen until the fresh open work-order count replaces the cached one", async () => { + let resolveRecheck: (value: { count: number; workOrderIds: number[] }) => void = () => {}; + getOpenWorkOrders.mockResolvedValueOnce({ count: 0, workOrderIds: [] }); + getOpenWorkOrders.mockReturnValueOnce( + new Promise((resolve) => { + resolveRecheck = resolve; + }), + ); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + let dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. This cannot be undone.", + ); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Delete this site?" })).not.toBeInTheDocument(), + ); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await waitFor(() => expect(getOpenWorkOrders).toHaveBeenCalledTimes(2)); + + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument(); + expect(within(dialog).getByLabelText("Checking open work orders")).toBeInTheDocument(); + + resolveRecheck({ count: 1, workOrderIds: [101] }); + + expect(await within(dialog).findByText(/It still has 1 open work order —/)).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled(); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it("keeps the site and shows the server's refusal when the API answers 403", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] }); + deleteSite.mockRejectedValue(forbidden()); + const toastError = vi.spyOn(toast, "error"); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText(/This cannot be undone/); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."), + ); + expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument(); + expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx new file mode 100644 index 00000000..8b5c2885 --- /dev/null +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -0,0 +1,238 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import type { Location, LocationListItem } from "@/domain/locations/types/location"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const getList = vi.fn(); +const getById = vi.fn(); +const update = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + update: (...args: unknown[]) => update(...args), + create: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + contactEmail: "dal1@acme.test", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const chicago: Location = { + id: 2, + name: "CHI2", + clientName: "Globex", + accountId: 8, + address: "100 Main St", + city: "Chicago", + state: "IL", + contacts: [{ id: 41, name: "Carol", phone: "(312) 555-0100" }], +}; + +function listRow(location: Location): LocationListItem { + return { + id: location.id ?? "", + name: location.name ?? "", + clientName: location.clientName ?? "", + sitePhone: location.sitePhone ?? "", + address: location.address ?? "", + city: location.city ?? "", + state: location.state ?? "", + zipCode: location.zipCode ?? "", + phone: location.contacts?.[0]?.phone ?? "", + contact: location.contacts?.[0]?.name ?? "", + contactEmail: location.contactEmail ?? "", + status: "Active", + accountId: location.accountId ?? "", + contacts: location.contacts, + }; +} + +function panel(): HTMLElement { + return screen.getByRole("region", { name: "Site details" }); +} + +async function openSite(code: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: code })); + const region = panel(); + await within(region).findByRole("heading", { name: code }); + return region; +} + +describe("Sites page detail panel", () => { + beforeEach(() => { + getList.mockReset(); + getById.mockReset(); + update.mockReset(); + getList.mockResolvedValue({ + items: [listRow(dallas), listRow(chicago)], + totalCount: 2, + page: 1, + pageSize: 12, + }); + getById.mockImplementation((id: string) => + Promise.resolve(String(id) === "2" ? chicago : dallas), + ); + }); + + it("opens a row in view mode with the address linked to Google Maps and a map preview", async () => { + renderWithProviders(, { withAuth: false }); + + const region = await openSite("DAL1"); + + expect(within(region).getAllByText("Acme Logistics").length).toBeGreaterThan(0); + const address = within(region).getByRole("link", { name: "3811 Distribution Dr" }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByText("TX · Area: West")).toBeInTheDocument(); + expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); + expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); + expect(within(region).getByText(/Main: Jane/)).toBeInTheDocument(); + expect(within(region).getByText(/Contact 2: Bob/)).toBeInTheDocument(); + expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); + }); + + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { + update.mockResolvedValue({}); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + const siteCode = within(region).getByRole("textbox", { name: "Site Code" }); + expect(siteCode).toHaveValue("DAL1"); + expect(siteCode).toBeDisabled(); + const save = within(region).getByRole("button", { name: "Save changes" }); + expect(save).toBeDisabled(); + + getById.mockResolvedValue({ ...dallas, city: "Fort Worth" }); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Fort Worth" }, + }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + name: "DAL1", + city: "Fort Worth", + sitePhone: "(214) 555-0100", + zipCode: "75201", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }), + ); + expect(await within(region).findByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Fort Worth")).toBeInTheDocument(); + }); + + it("edit mode: picking an address suggestion fills Street, City and State and enables Save", async () => { + update.mockResolvedValue({}); + getById.mockResolvedValue(dallas); + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + const save = within(region).getByRole("button", { name: "Save changes" }); + + const street = within(region).getByRole("combobox", { name: "Street Address" }); + await user.clear(street); + await user.type(street, "415 Murphy Rd"); + const picked = suggestAddresses("415 Murphy Rd").find((option) => option.city !== "Dallas"); + if (picked === undefined) throw new Error("expected a non-Dallas suggestion"); + await user.click(await screen.findByRole("option", { name: picked.label })); + + await waitFor(() => expect(save).toBeEnabled()); + expect(within(region).getByRole("textbox", { name: "City" })).toHaveValue(picked.city); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + address: "415 Murphy Rd", + city: picked.city, + state: picked.state, + }), + ); + }); + + it("Cancel discards edits and returns to view without an API call", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Houston" }, + }); + fireEvent.click(within(region).getByRole("button", { name: "Cancel" })); + + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Dallas")).toBeInTheDocument(); + expect(within(region).queryByText("Houston")).not.toBeInTheDocument(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps the table usable: another row swaps the panel content in place", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + fireEvent.click(screen.getByRole("button", { name: "CHI2" })); + + await within(panel()).findByRole("heading", { name: "CHI2" }); + expect(panel()).toBe(region); + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getAllByText("Globex").length).toBeGreaterThan(0); + expect(within(region).queryByText("Acme Logistics")).not.toBeInTheDocument(); + }); + + it("Escape closes the panel", async () => { + renderWithProviders(, { withAuth: false }); + await openSite("DAL1"); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index 68c3fa51..7ebf6040 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -29,7 +29,7 @@ function StateSelectHarness({ zipCode: "70068", contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", - status: "Active", + accountId: "7", }, }); diff --git a/src/test/app/(protected)/team-members/resend-invite-button.test.tsx b/src/test/app/(protected)/team-members/resend-invite-button.test.tsx new file mode 100644 index 00000000..fc9023c3 --- /dev/null +++ b/src/test/app/(protected)/team-members/resend-invite-button.test.tsx @@ -0,0 +1,61 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResendInviteButton } from "@/app/(protected)/team-members/_components/resend-invite-button"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { TeamMemberDetail } from "@/domain/team-members/types/team-member"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +vi.mock("@/domain/team-members/api/team-members-api", () => ({ + teamMembersApi: { resendInvite: vi.fn() }, +})); + +function member(overrides: Partial = {}): TeamMemberDetail { + return { + id: "user-7", + name: "Taylor Reed", + role: "Dispatcher", + color: "#0D9488", + email: "taylor@example.com", + phone: "555-0100", + status: "Active", + pendingRegistration: true, + serviceAreas: ["East"], + permissions: [], + ...overrides, + } as TeamMemberDetail; +} + +describe("ResendInviteButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-sends the invite for a pending member and confirms where it went", async () => { + vi.mocked(teamMembersApi.resendInvite).mockResolvedValue(undefined); + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await user.click(screen.getByRole("button", { name: "Resend invite" })); + + await waitFor(() => + expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Invite sent to taylor@example.com"), + ); + expect(teamMembersApi.resendInvite).toHaveBeenCalledWith("user-7"); + }); + + it.each([ + ["a registered member", { pendingRegistration: false }], + ["a deactivated pending member", { status: "Inactive" as const }], + ])("is not offered for %s", (_label, overrides) => { + renderWithProviders(, { withAuth: false }); + + expect(screen.queryByRole("button", { name: "Resend invite" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx index 05034d48..1816bb04 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); @@ -125,14 +125,14 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeEnabled(); fireEvent.click(revoke); expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument(); expect( screen.getByText( - "The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.", + "The approved uplift of $400.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.", ), ).toBeInTheDocument(); }); @@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeDisabled(); fireEvent.mouseOver(revoke); @@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument(); expect( screen.getByText("Uplifts you approve are archived here for reference."), diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx new file mode 100644 index 00000000..7f6bae19 --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx @@ -0,0 +1,187 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const UPLIFT = "$4,999,997,000.00"; +const REQUESTED_NTE_TOTAL = "$5,000,000,000.00"; + +const api = vi.hoisted(() => ({ + approve: vi.fn(), + reject: vi.fn(), + revoke: vi.fn(), +})); +const toastSuccess = vi.hoisted(() => vi.fn()); +const queueState = vi.hoisted(() => ({ + data: null as UpliftsQueueResult | null, +})); + +vi.mock("@/domain/uplifts/api/uplifts-api", () => ({ + upliftsApi: { + approve: (...args: unknown[]) => api.approve(...args), + reject: (...args: unknown[]) => api.reject(...args), + revoke: (...args: unknown[]) => api.revoke(...args), + }, +})); + +vi.mock("react-toastify", async (importOriginal) => ({ + ...(await importOriginal()), + toast: { error: vi.fn(), success: toastSuccess, warning: vi.fn() }, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: () => ({ + data: queueState.data, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ + useWorkOrderUplifts: () => ({ data: [], isLoading: false, isError: false }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +const pendingItem: UpliftQueueItem = { + id: 41, + status: "Pending", + currentNTE: 3000, + requestedNTE: 5_000_000_000, + delta: 4_999_997_000, + vendorReason: "Full replacement", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +const approvedItem: UpliftQueueItem = { + ...pendingItem, + id: 42, + status: "Approved", + decidedAt: "2026-01-16T10:00:00Z", + decidedByName: "Admin User", + canDecide: false, +}; + +function queueOf(item: UpliftQueueItem): UpliftsQueueResult { + return { items: [item], totalCount: 1, page: 1, pageSize: 25, pendingExposureTotal: null }; +} + +async function renderQueue() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); +} + +function amountCell(): HTMLElement { + const row = screen.getByLabelText(/open uplift details for/i); + const header = screen.getByRole("columnheader", { name: "Amount" }); + const index = Array.from(header.parentElement?.children ?? []).indexOf(header); + return row.querySelectorAll("td")[index] as HTMLElement; +} + +describe("Uplift Approvals shows one amount per request", () => { + beforeEach(() => { + api.approve.mockReset().mockResolvedValue(undefined); + api.reject.mockReset().mockResolvedValue(undefined); + api.revoke.mockReset().mockResolvedValue(undefined); + toastSuccess.mockReset(); + }); + + it("uses the uplift amount in the pending row, the detail modal and the approve toast", async () => { + queueState.data = queueOf(pendingItem); + await renderQueue(); + + expect(amountCell()).toHaveTextContent(UPLIFT); + expect(screen.getByRole("button", { name: `Approve ${UPLIFT}` })).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/open uplift details for/i)); + const modal = screen.getByRole("dialog"); + expect(within(modal).getByText(UPLIFT)).toBeInTheDocument(); + fireEvent.click(within(modal).getByRole("button", { name: `Approve ${UPLIFT}` })); + + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith(`Uplift approved — ${UPLIFT} on WO-99`), + ); + expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument(); + }); + + it("uses the uplift amount in the reject dialog title and the reject toast", async () => { + queueState.data = queueOf(pendingItem); + await renderQueue(); + + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + expect( + screen.getByRole("heading", { name: `Reject uplift of ${UPLIFT}?` }), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/reason for rejection/i), { + target: { value: "Out of scope" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Reject uplift" })); + + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith(`Uplift rejected — ${UPLIFT} on WO-99`), + ); + expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument(); + }); + + it("uses the uplift amount in the approved row, the revoke dialog and the revoke toast", async () => { + queueState.data = queueOf(approvedItem); + await renderQueue(); + + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); + expect(amountCell()).toHaveTextContent(UPLIFT); + + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + expect( + screen.getByText( + `The approved uplift of ${UPLIFT} on WO WO-99 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.`, + ), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/reason for revoking/i), { + target: { value: "Approved in error" }, + }); + fireEvent.click(screen.getByRole("button", { name: `Revoke ${UPLIFT}` })); + + await waitFor(() => + expect(toastSuccess).toHaveBeenCalledWith(`Uplift revoked — ${UPLIFT} on WO-99`), + ); + expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx new file mode 100644 index 00000000..778e94e3 --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx @@ -0,0 +1,235 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + UpliftQueueItem, + UpliftsQueueParams, + UpliftsQueueResult, +} from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +const queueState = vi.hoisted(() => ({ + byStatus: {} as Record, + errorByStatus: {} as Record, + requestedStatuses: [] as Array, +})); + +const baseItem: UpliftQueueItem = { + id: 1, + status: "Pending", + currentNTE: 500, + requestedNTE: 750, + delta: 250, + vendorReason: "Second hydro-jetting pass on the adjoining line", + requestedAt: "2026-06-21T10:05:00Z", + requestedByVendorName: "Alyssa Dispatcher", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-9", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 1024, + dispatchNumber: "DSP-1", + poNumber: "PO-1", + vendorCompanyName: "Drain Cleaning Bros", + workOrderId: 10, + dispatchId: 20, + woNumber: "WO-25603", + site: "CHI2", + serviceName: "Drains", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: 1, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +const newestRejection: UpliftQueueItem = { + ...baseItem, + id: 31, + status: "Rejected", + woNumber: "WO-31", + decidedAt: "2026-06-21T16:40:00Z", + decidedByName: "Adam Moussa", + decisionNote: "Outside this work order's scope — raise a separate reactive WO.", + canDecide: false, +}; + +const olderRejection: UpliftQueueItem = { + ...baseItem, + id: 30, + status: "Rejected", + woNumber: "WO-30", + requestedByVendorName: "Derek Dispatcher", + decidedAt: "2026-05-26T15:05:00Z", + decidedByName: "Grace Admin", + decisionNote: "Quote is above market for this scope.", + canDecide: false, +}; + +const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" }; +const approvedItem: UpliftQueueItem = { + ...baseItem, + id: 50, + status: "Approved", + woNumber: "WO-APPROVED", + decidedAt: "2026-06-01T09:00:00Z", + decidedByName: "Adam Moussa", +}; + +function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult { + return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null }; +} + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: (params: UpliftsQueueParams) => { + queueState.requestedStatuses.push(params.status); + const data = queueState.byStatus[params.status ?? ""]; + const error = queueState.errorByStatus[params.status ?? ""] ?? null; + return { + data, + isLoading: data === undefined && error === null, + isFetching: false, + error, + refetch: vi.fn(), + }; + }, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +async function openRejectedTab() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ })); + return screen.getByRole("table", { name: "rejected uplift requests" }); +} + +describe("Uplift Approvals rejected tab", () => { + beforeAll(async () => { + await import("@/app/(protected)/uplifts/index"); + }, 60_000); + + beforeEach(() => { + queueState.requestedStatuses = []; + queueState.errorByStatus = {}; + queueState.byStatus = { + Pending: page([pendingItem]), + Approved: page([approvedItem]), + Rejected: page([newestRejection, olderRejection], 12), + }; + }); + + it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ }); + expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument(); + expect(queueState.requestedStatuses).toContain("Rejected"); + }); + + it("never claims a count for a queue that is still loading or failed to load", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Forbidden"); + queueState.byStatus.Approved = undefined; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedPill = screen.getByLabelText("rejected uplift count unavailable"); + expect(rejectedPill).toHaveTextContent("—"); + expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—"); + expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument(); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + }); + + it("keeps the count and pager for kept rows when a rejected refetch fails", async () => { + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).getAllByRole("row")).toHaveLength(3); + expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByText(/of 12/)).toBeInTheDocument(); + }); + + it("shows the load error instead of an empty archive when the rejected queue fails", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument(); + expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument(); + expect(screen.queryByText(/of 0/)).not.toBeInTheDocument(); + }); + + it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => { + const table = await openRejectedTab(); + + const dataRows = within(table).getAllByRole("row").slice(1); + expect(dataRows).toHaveLength(2); + expect(dataRows[0]).toHaveTextContent("WO-31"); + expect(dataRows[1]).toHaveTextContent("WO-30"); + expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument(); + expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument(); + }); + + it("shows who requested, who rejected, when, and the rejection reason inline", async () => { + const table = await openRejectedTab(); + + for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) { + expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument(); + } + const [, firstRow] = within(table).getAllByRole("row"); + expect(firstRow).toHaveTextContent("Alyssa Dispatcher"); + expect(firstRow).toHaveTextContent("Adam Moussa"); + expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt)); + expect( + within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."), + ).toBeVisible(); + }); + + it("offers no action on a rejected uplift", async () => { + const table = await openRejectedTab(); + + expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument(); + for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) { + expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument(); + } + }); + + it("shows an icon and the empty copy when nothing has been rejected", async () => { + queueState.byStatus.Rejected = page([]); + const table = await openRejectedTab(); + + expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument(); + expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument(); + expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-approved-on-wo-dash.test.tsx b/src/test/app/(protected)/uplifts/uplift-approved-on-wo-dash.test.tsx new file mode 100644 index 00000000..2cd39a9c --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approved-on-wo-dash.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const queueState = vi.hoisted(() => ({ data: null as UpliftsQueueResult | null })); +const woUplifts = vi.hoisted(() => ({ + data: [] as Array> | null, + isError: false, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: () => ({ + data: queueState.data, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ + useWorkOrderUplifts: () => ({ + data: woUplifts.data, + isLoading: false, + isError: woUplifts.isError, + }), +})); + +const pendingItem: UpliftQueueItem = { + id: 41, + status: "Pending", + currentNTE: 100, + requestedNTE: 250, + delta: 150, + vendorReason: "Extra parts", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +function seedQueue(overrides: Partial) { + queueState.data = { + items: [{ ...pendingItem, ...overrides }], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: 250, + }; +} + +async function renderQueuePage() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); +} + +function approvedOnWoColumnText(): string { + const table = screen.getByRole("table", { name: "pending uplift requests" }); + const headers = within(table).getAllByRole("columnheader"); + const columnIndex = headers.findIndex((header) => header.textContent === "Approved on WO"); + const row = within(table).getByRole("row", { name: /Open uplift details for WO-99/ }); + return within(row).getAllByRole("cell")[columnIndex]?.textContent ?? ""; +} + +function modalTotalText(): string { + fireEvent.click(screen.getByRole("row", { name: /Open uplift details for WO-99/ })); + const dialog = screen.getByRole("dialog"); + const label = within(dialog).getByText("Total"); + return label.parentElement?.lastElementChild?.textContent ?? ""; +} + +describe("Uplift Approvals: Approved on WO", () => { + beforeEach(() => { + woUplifts.data = []; + woUplifts.isError = false; + }); + + it.each([ + { total: 0, expected: "—" }, + { total: null, expected: "—" }, + { total: 125, expected: "$125.00" }, + ])( + "renders $expected in the column and the modal Total for total $total", + async ({ total, expected }) => { + seedQueue({ approvedOnWoTotal: total }); + await renderQueuePage(); + + const column = approvedOnWoColumnText(); + const modalTotal = modalTotalText(); + + expect(column).toBe(expected); + expect(modalTotal).toBe(column); + }, + ); + + it("shows a dash in both places when the row total is unknown and the WO has nothing granted", async () => { + woUplifts.data = [ + { id: 1, status: "pending", amount: 250 }, + { id: 2, status: "rejected", amount: 80 }, + { id: 3, status: "revoked", amount: 40 }, + ]; + seedQueue({ approvedOnWoTotal: null }); + await renderQueuePage(); + + expect(approvedOnWoColumnText()).toBe("—"); + expect(modalTotalText()).toBe("—"); + }); + + it("keeps the modal Total as a dash when the WO uplift lookup fails but the row reports zero", async () => { + woUplifts.data = null; + woUplifts.isError = true; + seedQueue({ approvedOnWoAuto: 0, approvedOnWoAdmin: 0, approvedOnWoTotal: 0 }); + await renderQueuePage(); + + expect(approvedOnWoColumnText()).toBe("—"); + expect(modalTotalText()).toBe("—"); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx index 7711239c..ef431201 100644 --- a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -195,10 +195,10 @@ describe("UpliftDetailModal", () => { const footerButtons = screen .getAllByRole("button") .map((button) => button.textContent) - .filter((label) => label === "Reject" || label === "Approve" || label === "Revoke"); - expect(footerButtons).toEqual(["Reject", "Approve"]); + .filter((label) => label === "Reject" || label === "Approve $150.00" || label === "Revoke"); + expect(footerButtons).toEqual(["Reject", "Approve $150.00"]); - fireEvent.click(screen.getByRole("button", { name: "Approve" })); + fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" })); expect(onApprove).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Reject" })); expect(onReject).toHaveBeenCalled(); @@ -208,7 +208,7 @@ describe("UpliftDetailModal", () => { canApproveState.data = false; const { onApprove, onReject } = renderModal(); - const approve = screen.getByRole("button", { name: "Approve" }); + const approve = screen.getByRole("button", { name: "Approve $150.00" }); const reject = screen.getByRole("button", { name: "Reject" }); expect(approve).toBeDisabled(); expect(reject).toBeDisabled(); @@ -223,7 +223,7 @@ describe("UpliftDetailModal", () => { it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => { const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true }); - expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Approve/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); const revoke = screen.getByRole("button", { name: "Revoke" }); expect(revoke).toBeDisabled(); diff --git a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx index 66d0855d..0fedd04c 100644 --- a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx @@ -106,7 +106,7 @@ describe("Uplift Approvals decision flow", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("button", { name: "Approve" })); + fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" })); expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" }); expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); @@ -117,7 +117,7 @@ describe("Uplift Approvals decision flow", () => { renderWithProviders(); fireEvent.click(screen.getByRole("button", { name: "Reject" })); - expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Reject uplift of $150.00?" })).toBeInTheDocument(); expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(rejectMutate).not.toHaveBeenCalled(); @@ -142,7 +142,7 @@ describe("Uplift Approvals decision flow", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - const approve = screen.getByRole("button", { name: "Approve" }); + const approve = screen.getByRole("button", { name: "Approve $150.00" }); const reject = screen.getByRole("button", { name: "Reject" }); expect(approve).toBeDisabled(); expect(reject).toBeDisabled(); @@ -160,7 +160,7 @@ describe("Uplift Approvals decision flow", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Approve $150.00" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled(); }); }); diff --git a/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx new file mode 100644 index 00000000..e3640e91 --- /dev/null +++ b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdvDateRangeSection } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params"; +import { renderWithProviders } from "@/test/test-utils"; + +const onDraft = vi.fn<(draft: WorkOrderAdvancedFilters) => void>(); + +function latestDraft(): WorkOrderAdvancedFilters { + return onDraft.mock.calls[onDraft.mock.calls.length - 1][0]; +} + +function Harness() { + const [draft, setDraft] = useState(() => cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + useEffect(() => { + onDraft(draft); + }, [draft]); + return ; +} + +function openSection() { + onDraft.mockClear(); + renderWithProviders(); + const header = screen.queryByRole("button", { name: /Date range/i }); + if (header && !screen.queryByRole("button", { name: /^This week$/i })) { + fireEvent.click(header); + } +} + +describe("Advanced Filters date range", () => { + it("starts with no range selected, so the search spans every week", () => { + openSection(); + + for (const label of ["This week", "Last week", "Last 3 months", "Custom"]) { + expect(screen.getByRole("button", { name: new RegExp(`^${label}$`, "i") })).toHaveAttribute( + "aria-pressed", + "false", + ); + } + expect(toBoardSearchQueryParams({ filters: latestDraft() }).includeDateless).toBe(true); + }); + + it("narrows to This week once picked, and clicking it again clears the range", () => { + openSection(); + const thisWeek = screen.getByRole("button", { name: /^This week$/i }); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "true"); + const narrowed = toBoardSearchQueryParams({ filters: latestDraft() }); + expect(narrowed.datePreset).toBe("ThisWeek"); + expect(narrowed.includeDateless).toBeUndefined(); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "false"); + expect(latestDraft().dateRange).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx new file mode 100644 index 00000000..3bc7d21f --- /dev/null +++ b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx @@ -0,0 +1,268 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompletionTemplatePreview } from "@/app/(protected)/completion-docs/_components/completion-template-preview"; +import { CompDocPreview } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +// why: jsdom has no layout, so the paginator geometry is stubbed below. +const SHEET_WIDTH = 420; +const LETTERHEAD_HEIGHT = 200; +const BLOCK_HEIGHT = 300; +const SHEET_STRIDE = 614; +let sheetStride = SHEET_STRIDE; + +function isLetterhead(el: HTMLElement) { + return el.querySelector('img[alt="Seahaven Industries"]') !== null; +} + +function stubRect(this: HTMLElement): DOMRect { + const height = + this.style.display === "flow-root" + ? isLetterhead(this) + ? LETTERHEAD_HEIGHT + : BLOCK_HEIGHT + : 0; + return { width: SHEET_WIDTH, height } as DOMRect; +} + +const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop"); + +beforeEach(() => { + sheetStride = SHEET_STRIDE; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect); + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + if (this.dataset.testid !== "comp-doc-page") return 0; + return Array.from(this.parentElement?.children ?? []).indexOf(this) * sheetStride; + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (offsetTopDescriptor) { + Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTopDescriptor); + } +}); + +function sheets() { + return screen.getAllByTestId("comp-doc-page"); +} + +function expectEverySheetFramed() { + for (const sheet of sheets()) { + expect(within(sheet).getAllByTestId("comp-doc-page-top-bar")).toHaveLength(1); + expect(within(sheet).getAllByTestId("comp-doc-page-bottom-bar")).toHaveLength(1); + expect(sheet.style.minHeight).toBe("594px"); + } +} + +function expectLetterheadOnlyOnFirstSheet() { + const [first, ...rest] = sheets(); + expect(within(first).getByAltText("Seahaven Industries")).toBeInTheDocument(); + for (const sheet of rest) { + expect(within(sheet).queryByAltText("Seahaven Industries")).not.toBeInTheDocument(); + } +} + +function scrollDocumentTo(scrollTop: number) { + const scroller = screen.getByTestId("comp-doc-document"); + act(() => { + scroller.scrollTop = scrollTop; + fireEvent.scroll(scroller); + }); +} + +describe("Completion template preview pagination", () => { + const procedures = [1, 2, 3].map((n) => ({ + name: `Step group ${n}`, + description: `Do part ${n} of the work`, + })); + + it("lays the template out on separate A4 sheets with the letterhead once and bars on each", () => { + render( + , + ); + + expect(sheets()).toHaveLength(3); + expectEverySheetFramed(); + expectLetterheadOnlyOnFirstSheet(); + procedures.forEach((procedure, index) => { + expect(within(sheets()[index]).getByText(procedure.name)).toBeInTheDocument(); + }); + expect(screen.getByText(/Document preview/)).toHaveTextContent( + "Document preview · Page 1 of 3", + ); + }); + + it("updates Page X of Y as the reader scrolls through the sheets", () => { + render( + , + ); + + scrollDocumentTo(SHEET_STRIDE + 5); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3"); + + scrollDocumentTo(2 * SHEET_STRIDE + 5); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 3 of 3"); + + scrollDocumentTo(0); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3"); + }); + + it("renders a short template as one full A4 sheet without a page count", () => { + render( + , + ); + + expect(sheets()).toHaveLength(1); + expectEverySheetFramed(); + expect(screen.getByText(/Document preview/)).toHaveTextContent(/^Document preview$/); + }); + + it("adds a sheet when a procedure is added while editing", () => { + const { rerender } = render( + , + ); + expect(sheets()).toHaveLength(2); + + rerender( + , + ); + + expect(sheets()).toHaveLength(3); + expect(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument(); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3"); + }); + + it("re-reads the page being read when an edit moves the sheets but keeps the page count", () => { + const { rerender } = render( + , + ); + scrollDocumentTo(SHEET_STRIDE + 5); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3"); + + sheetStride = 800; + rerender( + , + ); + + expect(sheets()).toHaveLength(3); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3"); + }); +}); + +const site: CompDocSiteInfo = { + code: "SEA1", + street: "1 Main St", + city: "Seattle", + state: "WA", + zip: "98101", + label: "Seattle, WA", + fullAddress: "1 Main St, Seattle, WA 98101", + tech: "Tech One", + company: "Vendor Co", +}; + +const row = { + id: 42, + woNumber: "WO-42", + site: "SEA1", + pm: "HVAC", + company: "Vendor Co", + tech: "Tech One", + scheduledOn: "2026-06-01", +} as WorkOrderTableRow; + +describe("Work order completion document preview pagination", () => { + it("pages the procedure and PM comments through the shared A4 frame", () => { + render( + , + ); + + expect(sheets()).toHaveLength(2); + expectEverySheetFramed(); + expectLetterheadOnlyOnFirstSheet(); + expect(within(sheets()[1]).getByText("PM Comments")).toBeInTheDocument(); + expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent( + "Preview — not yet generated · Page 1 of 2", + ); + + scrollDocumentTo(SHEET_STRIDE); + expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent( + "Preview — not yet generated · Page 2 of 2", + ); + }); + + it("stamps NOT REQUIRED on the first sheet, above the greyed-out content", () => { + render( + , + ); + + const [first, second] = sheets(); + const stamp = within(first).getByText(/NOT REQUIRED/); + expect(stamp.closest('[style*="opacity: 0.5"]')).toBeNull(); + expect(within(first).getByTestId("comp-doc-page-top-bar").parentElement).toHaveStyle({ + opacity: "0.5", + }); + expect(within(second).queryByText(/NOT REQUIRED/)).not.toBeInTheDocument(); + }); + + it("keeps a document without comments on a single sheet", () => { + render( + , + ); + + expect(sheets()).toHaveLength(1); + expectEverySheetFramed(); + expect(screen.queryByText("PM Comments")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index 48c4241c..7b7769df 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => { }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + screen.getByText( + "Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)", + ), ).toBeInTheDocument(); expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); }); diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx index 48ccb432..9aae2d73 100644 --- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -1,19 +1,39 @@ import { createRef } from "react"; -import { fireEvent, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "react-toastify"; import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone"; -import { - MAX_WORK_ORDER_MEDIA_BYTES, - workOrderMediaAcceptAttribute, -} from "@/domain/work-orders/utils/work-order-media-limits"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; import { renderWithProviders } from "@/test/test-utils"; vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); + +const MB = 1_000_000; + +function phoneFile(name: string, type: string, size: number): File { + return { name, type, size } as File; +} + +function gallery(photos: number, videos: number): MediaFile[] { + const item = (id: string, fileType: "image" | "video") => + ({ id, fileType, isLegacy: false }) as unknown as MediaFile; + return [ + ...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")), + ...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")), + ]; +} + +function selectFiles(files: File[]) { + const input = document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files } }); +} describe("MediaUploaderDropzone", () => { - it("matches the AAP Photos accept copy and keeps it media-only", () => { + it("shows the SH-116 media contract and keeps the picker media-only", () => { renderWithProviders( ()} @@ -23,28 +43,139 @@ describe("MediaUploaderDropzone", () => { ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + screen.getByText( + "Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)", + ), ).toBeInTheDocument(); expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); expect(screen.queryByText(/PDF/)).not.toBeInTheDocument(); - expect(screen.queryByText(/DOC/)).not.toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute()); expect(input?.getAttribute("accept")).not.toContain(".pdf"); }); +}); - it("rejects an oversized media file with the generic 200 MB toast", () => { - renderWithProviders(); +describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + vi.mocked(probeVideoDurationSeconds).mockReset(); + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + }); - const oversizedFile = { - name: "mobile-clip.MP4", - type: "application/octet-stream", - size: MAX_WORK_ORDER_MEDIA_BYTES + 1, - } as File; - const input = document.querySelector('input[type="file"]'); + it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => { + const onUpload = vi.fn(); + renderWithProviders(); - fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } }); + const videos = [ + phoneFile("clip.mp4", "video/mp4", 60 * MB), + phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB), + phoneFile("IMG_0002.MOV", "", 60 * MB), + ]; + selectFiles(videos); - expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.'); + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("accepts an octet-stream video with a video extension", async () => { + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + }); + + it("rejects a 101 MB video with the generic message and no file name", async () => { + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("rejects an 11 MB photo", async () => { + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("rejects the 11th photo and the 4th video on a full work order", async () => { + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([ + phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB), + ]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2)); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos."); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("counts the whole work order's media when this tab shows only its share", async () => { + const onUpload = vi.fn(); + renderWithProviders( + , + ); + + selectFiles([ + phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB), + ]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2)); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos."); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("counts files within one selection against the remaining slots", async () => { + const onUpload = vi.fn(); + renderWithProviders(); + + const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB); + selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + }); + + it("rejects a video longer than 90 seconds when the duration is readable", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95); + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("does not block a video whose duration the browser cannot read", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const onUpload = vi.fn(); + renderWithProviders(); + + selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(toast.error).not.toHaveBeenCalled(); }); }); diff --git a/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx new file mode 100644 index 00000000..5c429789 --- /dev/null +++ b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections"; +import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + canContinueWizardStep1, + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("Overdue work order type", () => { + it("offers Overdue in the Create WO wizard and never asks for a severity", () => { + const onPatch = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ })); + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "Overdue", severity: null }), + ); + + const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" }; + rerender( + , + ); + expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument(); + expect(canContinueWizardStep1(overdueDraft)).toBe(true); + }); + + it("creates the work order with the Overdue enum and no severity", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "Overdue", + severity: 2, + locationId: "1", + siteCode: "BK5", + }); + + expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue); + expect(payload.severity ?? null).toBeNull(); + }); + + it("corrects a type inline to Overdue without opening the severity dialog", async () => { + const onSave = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" })); + await user.click(screen.getByRole("button", { name: /^Overdue$/ })); + + expect(onSave).toHaveBeenCalledWith("Overdue", null); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("reads the Overdue enum back from the board as the Overdue type", () => { + const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue }); + expect(row.type).toBe("Overdue"); + }); + + it("closes with the completion document, not photos", () => { + const base = { + id: "1", + dispatcherId: "sarah", + dispatcherName: "Sarah Chen", + company: "Gateway Plumbing", + vendorId: "", + tech: "", + completedDate: "2026-09-24", + woNumber: "24971", + pm: "Backflow Preventers", + mediaCount: 0, + }; + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }), + ).toEqual(["Completion document"]); + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }), + ).toEqual([]); + + const row = { + id: "1", + type: "Overdue", + pm: "Backflow Preventers", + docStatus: "No", + rowVersion: "1", + }; + render( + , + ); + expect(screen.getByText("Completion Document")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled(); + }); + + it("keeps a separate Past Due status toggle in the advanced filters", () => { + let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS }; + const setDraft = vi.fn( + (update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => { + draft = update(draft); + }, + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Status/ })); + fireEvent.click(screen.getByRole("button", { name: /Past Due/ })); + + expect(draft.pastDue).toBe(true); + expect(draft.types.size).toBe(0); + expect(buildAdvancedFilterChips(draft)).toContainEqual( + expect.objectContaining({ group: "Status", value: "Past Due" }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index 7dcdc7d2..7633c94f 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => { expect(getMediaContent).not.toHaveBeenCalled(); }); - it("shows Extra Docs accept copy with PDF DOC and 200MB", () => { + it("shows Extra Docs accept copy with the SH-116 media contract", () => { renderWithProviders( , ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"), + screen.getByText( + "Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB", + ), ).toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toContain(".pdf"); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx new file mode 100644 index 00000000..f179079d --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); + +const MB = 1_000_000; + +function phoneFile(name: string, type: string, size: number): File { + return { name, type, size } as File; +} + +function selectFiles(files: File[]) { + const input = document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files } }); +} + +describe("SlideOverExtrasTab SH-116 media contract", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + vi.mocked(probeVideoDurationSeconds).mockReset(); + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + }); + + it("rejects a video longer than 90 seconds before upload", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95); + const onUpload = vi.fn(); + renderWithProviders( + , + ); + + selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("rejects the 11th photo and 4th video counted across the whole work order", async () => { + const onUpload = vi.fn(); + const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB); + renderWithProviders( + , + ); + + selectFiles([ + phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB), + report, + ]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(onUpload).toHaveBeenCalledWith([report]); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos."); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + }); + + it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const onUpload = vi.fn(); + const video = phoneFile("IMG_0703.MOV", "", 60 * MB); + renderWithProviders( + , + ); + + selectFiles([video]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video])); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("ignores a second pick while the first one is still being checked", async () => { + let finishProbe: ((seconds: number) => void) | undefined; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve; + }), + ); + const onUpload = vi.fn(); + renderWithProviders( + , + ); + const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB); + const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB); + + selectFiles([first]); + await waitFor(() => expect(finishProbe).toBeDefined()); + expect(document.querySelector('input[type="file"]')).toBeDisabled(); + selectFiles([second]); + finishProbe?.(30); + + await waitFor(() => expect(onUpload).toHaveBeenCalledOnce()); + expect(onUpload).toHaveBeenCalledWith([first]); + expect(probeVideoDurationSeconds).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-completed-date-clear.test.tsx b/src/test/app/(protected)/workorders/wo-table-completed-date-clear.test.tsx new file mode 100644 index 00000000..6e2c5ccd --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-completed-date-clear.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 9, + woNumber: "24818000001", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "2026-07-20", + targetWeek: "2026-07-15", + dayGroup: "07/15", + dayLabel: "Tue", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "09:00 – 10:00", + status: "Incomplete", + docStatus: "Yes", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "2026-07-22", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 1, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +const handlers: WoTableRowHandlers = { + onRowClick: vi.fn(), + onView: vi.fn(), + onEdit: vi.fn(), + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleDate: vi.fn(), + onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenCompDoc: vi.fn(), + onOpenUplifts: vi.fn(), + onReorder: vi.fn(), + onDragStart: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragEnd: vi.fn(), +}; + +function renderCells(row: WorkOrderTableRow, viewOnly = false) { + const onPatchRow = vi.fn(); + renderWithProviders( + + + + + + +
    , + ); + return onPatchRow; +} + +describe("WoTableRowServiceCells completed date Clear (SH-390)", () => { + it("shows Clear in the completed-date editor and patches completedDate to empty", async () => { + const onPatchRow = renderCells(makeRow()); + + fireEvent.click(screen.getByRole("button", { name: /07-20/ })); + + fireEvent.click(await screen.findByRole("button", { name: /clear/i })); + + expect(onPatchRow).toHaveBeenCalledWith({ completedDate: "" }); + }); + + it("offers no Clear when the completed date is already empty", async () => { + renderCells(makeRow({ completedDate: "" })); + + fireEvent.click(screen.getByRole("button", { name: "—" })); + + expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument(); + }); + + it("offers no Clear on a Completed (view-only) row", async () => { + renderCells(makeRow({ status: "Completed" }), true); + + fireEvent.click(screen.getByRole("button", { name: /07-20/ })); + + expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-date-clear.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-date-clear.test.tsx new file mode 100644 index 00000000..84275975 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-date-clear.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 9, + woNumber: "24818000001", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "2026-07-20", + targetWeek: "2026-07-15", + dayGroup: "07/15", + dayLabel: "Tue", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "09:00 – 10:00", + status: "Incomplete", + docStatus: "Yes", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "2026-07-22", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 1, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +function makeHandlers(): WoTableRowHandlers { + return { + onRowClick: vi.fn(), + onView: vi.fn(), + onEdit: vi.fn(), + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleDate: vi.fn(), + onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenCompDoc: vi.fn(), + onOpenUplifts: vi.fn(), + onReorder: vi.fn(), + onDragStart: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragEnd: vi.fn(), + }; +} + +function renderRow(row: WorkOrderTableRow, handlers: WoTableRowHandlers) { + renderWithProviders( + + + + +
    , + { withAuth: false }, + ); +} + +describe("WoTableRow completed date Clear (SH-390)", () => { + it("clears the completed date of an open row through the board patch", async () => { + const handlers = makeHandlers(); + renderRow(makeRow(), handlers); + + fireEvent.click(screen.getByRole("button", { name: /07-20/ })); + fireEvent.click(await screen.findByRole("button", { name: /clear/i })); + + expect(handlers.onPatch).toHaveBeenCalledTimes(1); + expect(vi.mocked(handlers.onPatch).mock.calls[0].slice(0, 2)).toEqual([ + 9, + { completedDate: "" }, + ]); + }); + + it.each(["Completed", "Canceled"] as const)("offers no Clear on a %s row", async (status) => { + const handlers = makeHandlers(); + renderRow(makeRow({ status }), handlers); + + fireEvent.click(screen.getByRole("button", { name: /07-20/ })); + + expect(await screen.findByRole("button", { name: "20" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /clear/i })).not.toBeInTheDocument(); + expect(handlers.onPatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx new file mode 100644 index 00000000..37116300 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); + +function renderCells(row: WorkOrderTableRow, onOpenUplifts = vi.fn()) { + const handlers = { onOpenUplifts } as unknown as WoTableRowHandlers; + renderWithProviders( + + + + + + +
    , + { withAuth: false }, + ); + return onOpenUplifts; +} + +describe("WoTableRowServiceCells uplift column", () => { + it.each(["Completed", "Canceled"] as const)( + "opens the read-only uplift history from a dash on a %s work order with no live uplift", + (status) => { + const row = { + id: 7, + status, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + } as WorkOrderTableRow; + + const onOpenUplifts = renderCells(row); + + const trigger = screen.getByRole("button", { name: /view uplifts/i }); + expect(screen.queryByRole("button", { name: /add uplift/i })).not.toBeInTheDocument(); + fireEvent.click(trigger); + expect(onOpenUplifts).toHaveBeenCalledWith(row); + }, + ); + + it("offers Add on an open work order with no live uplift", () => { + const row = { + id: 8, + status: "Scheduled", + upliftSummary: { hasUplift: false, pendingCount: 0 }, + } as WorkOrderTableRow; + + const onOpenUplifts = renderCells(row); + + fireEvent.click(screen.getByRole("button", { name: /add uplift/i })); + expect(onOpenUplifts).toHaveBeenCalledWith(row); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx new file mode 100644 index 00000000..4f17793c --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -0,0 +1,170 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; +import { + buildWorkOrderIdsHref, + WORK_ORDER_IDS_MAX, +} from "@/domain/work-orders/utils/work-order-ids-filter"; +import { renderWithProviders } from "@/test/test-utils"; + +/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */ +function BoardFilterHarness() { + const filters = useWorkOrdersListFilters(); + useDashboardDrilldownFilters(filters.applyAdvancedFilters); + const location = useLocation(); + const params = buildResolvedBoardSearchParams({ + advApplied: filters.advApplied, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + return ( + <> + + {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} + + {location.search} + + + {filters.advApplied != null && ( + + )} + + ); +} + +function searchParams(): Record | "weekly board" { + const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? ""; + return text === "weekly board" ? text : (JSON.parse(text) as Record); +} + +describe("Work Orders exact id filter", () => { + it("opens the board on exactly the linked work orders and shows the selection chip", async () => { + renderWithProviders(, { + route: "/workorders?ids=4,9,4", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("4,9"); + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + expect(params.types).toBeUndefined(); + + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/); + }); + + it("returns to the weekly board when the selection chip is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: buildWorkOrderIdsHref([12]), + withAuth: false, + }); + + expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove filter" })); + + expect(searchParams()).toBe("weekly board"); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply rescheduled only" })); + + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.rescheduled).toBe(true); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("keeps the selection when the panel is applied without a filter", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply unchanged" })); + + expect((searchParams() as Record).ids).toBe("4,9"); + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + }); + + it("never sends more ids than the server accepts, however the selection was built", () => { + const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1)); + const params = buildResolvedBoardSearchParams({ + advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids }, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX); + }); + + it("ignores other drill-down params sent alongside the id set", async () => { + renderWithProviders(, { + route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("5"); + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + }); + + it("keeps the regular drill-down filters when no ids are given", async () => { + renderWithProviders(, { + route: "/workorders?statuses=Scheduled", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.statuses).toEqual([expect.any(Number)]); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx new file mode 100644 index 00000000..f6e70cda --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; +import { REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const row = { + id: 42, + woNumber: "WO-42", + type: "PM", + status: "Scheduled", +} as WorkOrderTableRow; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function respondToCreate(status: number, message: string) { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.method === "POST") { + return new Response(JSON.stringify({ code: "Forbidden", message }), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ status: "Success", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + return fetchMock; +} + +async function submitInternalCreate(status: number, serverMessage: string) { + const fetchMock = respondToCreate(status, serverMessage); + renderWithProviders( + , + { withAuth: true }, + ); + + fireEvent.change(await screen.findByLabelText(/amount/i), { target: { value: "125" } }); + fireEvent.change(screen.getByLabelText(/^notes/i), { target: { value: "Additional work" } }); + fireEvent.click(screen.getByRole("button", { name: /request uplift/i })); + + await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); + expect( + fetchMock.mock.calls.some(([input, init]) => { + const request = input instanceof Request ? input : new Request(input, init); + return ( + request.method === "POST" && + new URL(request.url).pathname.endsWith("/api/workorders/42/uplifts") + ); + }), + ).toBe(true); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("internal work-order uplift create error copy", () => { + it("shows the exact role denial for a 403", async () => { + const message = await submitInternalCreate(403, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE); + + expect(message).toBe(REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE); + }); + + it("keeps unrelated 403 permission denials generic", async () => { + const message = await submitInternalCreate(403, "accountId 42 is forbidden"); + + expect(message).toBe("You do not have permission to perform this action."); + }); + + it("keeps unknown non-403 server diagnostics generic", async () => { + const diagnostic = "SqlException: workOrderId 42 row rejected"; + const message = await submitInternalCreate(422, diagnostic); + + expect(message).toBe("Failed to create uplift"); + expect(message).not.toContain(diagnostic); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx index eb32a4cf..ed89ef80 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -25,6 +25,12 @@ const pendingUplift: WorkOrderUplift = { const mockState = vi.hoisted(() => ({ uplifts: [] as WorkOrderUplift[], cancel: vi.fn(), + authUser: { id: "u1", userRoles: "Dispatcher" }, +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: mockState.authUser }), })); const baseRow: WorkOrderTableRow = { @@ -229,6 +235,41 @@ describe("SlideOverUpliftsTab affordances", () => { ); expect(mockState.cancel).toHaveBeenCalledWith(pendingUplift.id, expect.any(Object)); }); + + it("shows no Revoke to an admin on the auto-approved uplift they requested", () => { + mockState.authUser = { id: "admin-1", userRoles: "Admin" }; + mockState.uplifts = [{ ...pendingUplift, status: "auto_approved", requestedById: "admin-1" }]; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument(); + }); + + it("shows Revoke to the dispatcher on the auto-approved uplift they requested", () => { + mockState.authUser = { id: "dispatcher-1", userRoles: "Dispatcher" }; + mockState.uplifts = [ + { ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }, + ]; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); + }); }); describe("WorkOrderUpliftListItem revoke affordances", () => { @@ -238,6 +279,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { uplift={{ ...pendingUplift, status: "approved" }} readOnly={false} currentUserId="admin-1" + currentUserIsAdmin onRevoke={vi.fn()} />, { withAuth: false }, @@ -252,6 +294,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }} readOnly={false} currentUserId="dispatcher-1" + currentUserIsAdmin={false} onRevoke={vi.fn()} />, { withAuth: false }, @@ -274,6 +317,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { }} readOnly={false} currentUserId="dispatcher-1" + currentUserIsAdmin={false} onRevoke={vi.fn()} />, { withAuth: false }, @@ -294,6 +338,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { }} readOnly={false} currentUserId="someone-else" + currentUserIsAdmin={false} />, { withAuth: false }, ); @@ -307,6 +352,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }} readOnly currentUserId="dispatcher-1" + currentUserIsAdmin={false} onRevoke={vi.fn()} />, { withAuth: false }, diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 6e64219f..3927bfaf 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -367,7 +367,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(unscheduled).toHaveAttribute("aria-pressed", "true"); expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute( "aria-pressed", - "true", + "false", ); expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument(); expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument(); diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx new file mode 100644 index 00000000..d640dc3c --- /dev/null +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -0,0 +1,425 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { toast } from "react-toastify"; +import { Route, Routes } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import InviteRegistrationPage from "@/app/invite"; +import { getAuthUser } from "@/lib/auth/auth-storage"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +const TOKEN = "Zm9yLXRlc3RzLW9ubHktbm90LWEtcmVhbC10b2tlbg"; +const CODE = "246810"; +const DETAILS = { + name: "Taylor Reed", + role: "Dispatcher", + email: "taylor@example.com", + phone: "555-0100", +}; +const SESSION = { + token: "session-jwt", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: DETAILS.email, + userRoles: "Dispatcher", + phoneNumber: "555-123-4567", + fullname: "Taylor Reed", + id: "user-7", +}; + +type Call = { path: string; url: string; body: Record }; + +type ServerOptions = { + sendCodeTooSoon?: boolean; + resendTooSoon?: boolean; + /** The admin re-sent the invite after the code went out, revoking this token. */ + revokedBeforeVerify?: boolean; + rejectPasswordAtFinish?: boolean; + /** How the first `resolve` call fails before the server answers normally. */ + resolveFailsOnce?: "network" | "server-error"; +}; + +const INVALID_INVITE_BODY = { + code: "invalid_invite", + message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", +}; +const PASSWORD_REJECTED_MESSAGE = "Choose a different password"; + +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** A fake of the four anonymous invite endpoints, scoped to one valid token. */ +function installInviteServer({ + sendCodeTooSoon = false, + resendTooSoon = false, + revokedBeforeVerify = false, + rejectPasswordAtFinish = false, + resolveFailsOnce, +}: ServerOptions = {}) { + const calls: Call[] = []; + let sends = 0; + let resolves = 0; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, ""); + const body = (await request.json()) as Record; + calls.push({ path, url: request.url, body }); + + if (path === "resolve" && resolveFailsOnce && ++resolves === 1) { + if (resolveFailsOnce === "network") { + throw new TypeError("Failed to fetch"); + } + return json(500, { message: "Internal Server Error" }); + } + + if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { + return json(400, INVALID_INVITE_BODY); + } + + switch (path) { + case "resolve": + return json(200, DETAILS); + case "send-code": { + sends += 1; + const refuse = sends === 1 ? sendCodeTooSoon : resendTooSoon; + return refuse + ? json(429, { + code: "resend_too_soon", + message: "Please wait a moment before requesting another code", + retryAfterSeconds: 42, + }) + : json(200, { message: "Code sent" }); + } + case "verify-code": + return body.code === CODE + ? json(200, { message: "Email confirmed" }) + : json(400, { + code: "code_incorrect", + message: "Incorrect code — check your email and try again", + }); + case "complete": + return rejectPasswordAtFinish + ? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE }) + : json(200, SESSION); + default: + return json(404, {}); + } + }); + + return calls; +} + +function renderInvite(hash: string) { + return renderWithProviders( + + } /> + Dashboard home} /> + Login page} /> + , + { route: `/invite${hash}` }, + ); +} + +function input(label: RegExp) { + return screen.getByLabelText(label, { selector: "input" }); +} + +function ruleStates() { + const list = screen.getByRole("list", { name: "Password requirements" }); + return Object.fromEntries( + within(list) + .getAllByRole("listitem") + .map((item) => [item.getAttribute("data-rule"), item.getAttribute("data-met")]), + ); +} + +async function completePasswordStep(user: ReturnType) { + await screen.findByRole("heading", { name: "Create your password" }); + await user.type(input(/^password/i), "Abc1!x"); + await user.type(input(/^confirm password/i), "Abc1!x"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await screen.findByRole("heading", { name: "Confirm your email" }); +} + +async function completeCodeStep(user: ReturnType) { + await user.type(input(/^confirmation code/i), CODE); + await user.click(screen.getByRole("button", { name: "Continue" })); + await screen.findByRole("heading", { name: "Review your info" }); +} + +describe("Invite registration", { timeout: 15_000 }, () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows the generic unavailable message for an unknown token", async () => { + const calls = installInviteServer(); + renderInvite("#not-a-real-token"); + + expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); + expect( + screen.getByText( + "This invite link is invalid or has expired. Ask your admin to send a new invite.", + ), + ).toBeInTheDocument(); + expect(calls.map((call) => call.path)).toEqual(["resolve"]); + expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login"); + }); + + it.each(["network", "server-error"] as const)( + "offers a retry instead of the dead-invite message when resolve fails (%s)", + async (failure) => { + const calls = installInviteServer({ resolveFailsOnce: failure }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + + expect( + await screen.findByRole("heading", { name: "Couldn't load your invite" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "We couldn't reach the server to check your invite. Check your connection and try again.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Invite unavailable" })).not.toBeInTheDocument(); + expect( + screen.queryByText( + "This invite link is invalid or has expired. Ask your admin to send a new invite.", + ), + ).not.toBeInTheDocument(); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect( + await screen.findByRole("heading", { name: "Create your password" }), + ).toBeInTheDocument(); + expect(calls.map((call) => call.path)).toEqual(["resolve", "resolve"]); + }, + ); + + it("does not call the server when the link carries no token", async () => { + const calls = installInviteServer(); + renderInvite(""); + + expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); + expect(calls).toHaveLength(0); + }); + + it("step 1 rejects a password missing a rule, shows the live checklist, and requires a match", async () => { + installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + + expect(await screen.findByText("Step 1 of 3")).toBeInTheDocument(); + expect(screen.getByText("Setting up the account for Taylor Reed")).toBeInTheDocument(); + const continueButton = screen.getByRole("button", { name: "Continue" }); + + await user.type(input(/^password/i), "abcdefgh"); + await user.type(input(/^confirm password/i), "abcdefgh"); + expect(ruleStates()).toEqual({ + length: "true", + uppercase: "false", + number: "false", + special: "false", + }); + expect(continueButton).toBeDisabled(); + + await user.clear(input(/^password/i)); + await user.type(input(/^password/i), "Abc1!x"); + expect(ruleStates()).toEqual({ + length: "true", + uppercase: "true", + number: "true", + special: "true", + }); + await user.click(input(/^confirm password/i)); + await user.tab(); + expect(await screen.findByText("Passwords don't match")).toBeInTheDocument(); + expect(continueButton).toBeDisabled(); + + await user.clear(input(/^confirm password/i)); + await user.type(input(/^confirm password/i), "Abc1!x"); + expect(continueButton).toBeEnabled(); + }); + + it("step 2 shows a clear error for a wrong code, resends on request, and advances on the right code", async () => { + const calls = installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + + expect(screen.getByText("Step 2 of 3")).toBeInTheDocument(); + expect(screen.getByText(`Enter the 6-digit code sent to ${DETAILS.email}`)).toBeInTheDocument(); + const continueButton = screen.getByRole("button", { name: "Continue" }); + await user.type(input(/^confirmation code/i), "123"); + expect(continueButton).toBeDisabled(); + + await user.type(input(/^confirmation code/i), "456"); + await user.click(continueButton); + expect( + await screen.findByText("Incorrect code — check your email and try again"), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Confirm your email" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Resend code" })); + await waitFor(() => + expect(vi.mocked(toast.success)).toHaveBeenCalledWith(`Code resent to ${DETAILS.email}`), + ); + expect(calls.filter((call) => call.path === "send-code")).toHaveLength(2); + + await user.clear(input(/^confirmation code/i)); + await completeCodeStep(user); + expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); + }); + + it("shows the cooldown message when a resend is refused", async () => { + installInviteServer({ resendTooSoon: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + + await user.click(screen.getByRole("button", { name: "Resend code" })); + + expect( + await screen.findByText("Please wait a moment before requesting another code"), + ).toBeInTheDocument(); + expect(vi.mocked(toast.success)).not.toHaveBeenCalled(); + }); + + it("still advances to step 2 when a code was already sent moments ago", async () => { + installInviteServer({ sendCodeTooSoon: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + + await completePasswordStep(user); + + expect(screen.getByText("Step 2 of 3")).toBeInTheDocument(); + }); + + it("step 3 keeps name, role and email read-only, lets the member edit phone, and finishing signs them in", async () => { + const calls = installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + + for (const value of [DETAILS.name, DETAILS.role, DETAILS.email]) { + expect(screen.getByText(value)).toBeInTheDocument(); + expect(screen.queryByDisplayValue(value)).not.toBeInTheDocument(); + } + expect(screen.getAllByRole("textbox")).toHaveLength(1); + const phone = input(/^phone/i); + expect(phone).toHaveValue(DETAILS.phone); + expect(phone).toHaveAttribute("type", "tel"); + + await user.clear(phone); + await user.type(phone, "555-123-4567"); + await user.click(screen.getByRole("button", { name: /finish registration/i })); + + expect(await screen.findByText("Dashboard home")).toBeInTheDocument(); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith( + "Registration complete — welcome to Seahaven", + ); + expect(getAuthUser()).toMatchObject({ token: "session-jwt", id: "user-7" }); + expect(calls.find((call) => call.path === "complete")?.body).toEqual({ + token: TOKEN, + password: "Abc1!x", + phone: "555-123-4567", + }); + }); + + it("step 3 blocks finishing with an invalid phone and never calls the server", async () => { + const calls = installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + + await user.clear(input(/^phone/i)); + await user.type(input(/^phone/i), "call me"); + await user.click(screen.getByRole("button", { name: /finish registration/i })); + + expect(await screen.findByText("Enter a valid phone number")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Review your info" })).toBeInTheDocument(); + expect(calls.some((call) => call.path === "complete")).toBe(false); + }); + + it("sends an empty phone when the member clears it", async () => { + const calls = installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + + await user.clear(input(/^phone/i)); + await user.click(screen.getByRole("button", { name: /finish registration/i })); + await screen.findByText("Dashboard home"); + + expect(calls.find((call) => call.path === "complete")?.body.phone).toBe(""); + }); + + it("ends the wizard with the generic message when the invite is revoked mid-way", async () => { + installInviteServer({ revokedBeforeVerify: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + + await user.type(input(/^confirmation code/i), CODE); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); + expect(screen.getByText(INVALID_INVITE_BODY.message)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Confirm your email" })).not.toBeInTheDocument(); + }); + + it("returns to step 1 with the server's message when the password is rejected at finish", async () => { + installInviteServer({ rejectPasswordAtFinish: true }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + + await user.click(screen.getByRole("button", { name: /finish registration/i })); + + expect( + await screen.findByRole("heading", { name: "Create your password" }), + ).toBeInTheDocument(); + expect(screen.getByText(PASSWORD_REJECTED_MESSAGE)).toBeInTheDocument(); + expect(getAuthUser()).toBeNull(); + }); + + it("sends the invite token only in request bodies, never in a URL", async () => { + const calls = installInviteServer(); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + await completePasswordStep(user); + await completeCodeStep(user); + await user.click(screen.getByRole("button", { name: /finish registration/i })); + await screen.findByText("Dashboard home"); + + expect(calls.map((call) => call.path)).toEqual([ + "resolve", + "send-code", + "verify-code", + "complete", + ]); + for (const call of calls) { + expect(call.url).not.toContain(TOKEN); + expect(call.body.token).toBe(TOKEN); + } + }); +}); diff --git a/src/test/app/v/uplift-request-card.test.tsx b/src/test/app/v/uplift-request-card.test.tsx new file mode 100644 index 00000000..c378a21c --- /dev/null +++ b/src/test/app/v/uplift-request-card.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card"; +import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; + +function request(overrides: Partial = {}): VendorPortalUpliftRequest { + return { + id: 7, + status: "Pending", + requestedNTE: 1500, + currentNTE: 1000, + vendorReason: "Extra parts", + raisedByVendor: true, + ...overrides, + }; +} + +function renderCard(req: VendorPortalUpliftRequest) { + return render( + , + ); +} + +describe("UpliftRequestCard actions", () => { + it("offers Withdraw on a pending request the vendor raised", () => { + renderCard(request({ status: "Pending" })); + + expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument(); + }); + + it("offers Withdraw and Revise when changes are requested on a request the vendor raised", () => { + renderCard(request({ status: "ChangesRequested" })); + + expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /revise/i })).toBeInTheDocument(); + }); + + it("shows a work-order increase as the new NTE total", () => { + renderCard(request({ raisedByVendor: false, requestedNTE: 90, currentNTE: 600 })); + + expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent( + "$690.00 (was $600.00)", + ); + }); + + it("shows a vendor request's requested total unchanged", () => { + renderCard(request({ raisedByVendor: true, requestedNTE: 900, currentNTE: 600 })); + + expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent( + "$900.00 (was $600.00)", + ); + }); + + it("treats a request without the raised-by flag as vendor-raised", () => { + renderCard(request({ raisedByVendor: undefined, requestedNTE: 900, currentNTE: 600 })); + + expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent( + "$900.00 (was $600.00)", + ); + expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument(); + }); + + it.each(["Pending", "ChangesRequested"])( + "shows a %s request raised from the work order read-only", + (status) => { + renderCard(request({ status, raisedByVendor: false, requestedNTE: 90, currentNTE: 600 })); + + expect(screen.getByText(status)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /withdraw request/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument(); + }, + ); +}); diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx index 91cfc13e..d09e0cca 100644 --- a/src/test/app/v/uplift-request-form.test.tsx +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -1,6 +1,9 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); function renderForm(overrides: Partial> = {}) { return render( @@ -38,6 +41,70 @@ describe("UpliftRequestForm revise mode", () => { expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument(); }); + it("refuses photo evidence when the work order already has 10 photos", async () => { + renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } }); + + fireEvent.change(screen.getByLabelText(/evidence/i), { + target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] }, + }); + + expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument(); + }); + + it("offers and accepts the same phone photo and video types as the upload allowlist", async () => { + renderForm(); + + const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)"); + expect(input.getAttribute("accept")?.split(",")).toEqual( + expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]), + ); + + fireEvent.change(input, { + target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] }, + }); + expect( + await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."), + ).toBeInTheDocument(); + + fireEvent.change(input, { + target: { files: [new File(["heic"], "site.heic", { type: "" })] }, + }); + await waitFor(() => + expect( + screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."), + ).not.toBeInTheDocument(), + ); + }); + + it("holds submit while a picked video is still being checked, then submits that video", async () => { + let finishProbe: (seconds: number | null) => void = () => {}; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => new Promise((resolve) => (finishProbe = resolve)), + ); + const onSubmit = vi.fn(); + renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit }); + + const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" }); + fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } }); + + const checking = await screen.findByRole("button", { name: "Checking evidence…" }); + expect(checking).toBeDisabled(); + fireEvent.submit(checking.closest("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument(); + + finishProbe(30); + const submitButton = await screen.findByRole("button", { name: /submit revision/i }); + expect(submitButton).toBeEnabled(); + fireEvent.click(submitButton); + + expect(onSubmit).toHaveBeenCalledWith({ + amount: 1200, + reason: "Extra parts", + evidenceFile: video, + }); + }); + it("reports the current NTE threshold in the actionable error", () => { renderForm({ initialRequested: "900", currentNte: 1000 }); diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index e8a33e78..5bfd9c05 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; import { renderWithProviders } from "@/test/test-utils"; +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); + const quarantinedDocument: VendorPortalDocument = { id: 12, originalFileName: "completion.pdf", @@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => { }); expect(await screen.findByRole("alert")).toHaveTextContent( - "Only PDF, JPG, and PNG files are allowed.", + "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.", ); expect(upload).not.toHaveBeenCalled(); }); @@ -62,6 +65,162 @@ describe("VendorPortalDocuments", () => { expect(screen.getByText("Processing")).toBeInTheDocument(); }); + it.each([ + ["animation.gif", "image/gif"], + ["clip.webm", "video/webm"], + ])("rejects %s (%s), which the server does not accept", async (name, type) => { + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.change(screen.getByLabelText("Choose completion document"), { + target: { files: [new File(["x"], name, { type })] }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.", + ); + expect(upload).not.toHaveBeenCalled(); + }); + + describe("SH-116 media contract", () => { + const MB = 1_000_000; + const renderDocuments = () => + renderWithProviders( + , + { withAuth: false }, + ); + const choose = (file: File) => + fireEvent.change(screen.getByLabelText("Choose completion document"), { + target: { files: [file] }, + }); + + it("states the limit for every accepted kind, including PDFs", () => { + renderDocuments(); + + expect( + screen.getByText( + "Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB", + ), + ).toBeInTheDocument(); + }); + + it("uploads a ~60 MB phone MOV whose browser type is empty", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const upload = vi + .spyOn(vendorPortalApi, "uploadDocument") + .mockResolvedValue(quarantinedDocument); + renderDocuments(); + + const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File; + choose(video); + + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + expect(upload.mock.calls[0][0].file).toBe(video); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it.each([ + [ + { name: "clip.mp4", type: "video/mp4", size: 101 * MB }, + "Videos must be 100 MB or smaller.", + ], + [ + { name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB }, + "Photos must be 10 MB or smaller.", + ], + ])("rejects %o with a generic message", async (file, message) => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderDocuments(); + + choose(file as File); + + expect(await screen.findByRole("alert")).toHaveTextContent(message); + expect(upload).not.toHaveBeenCalled(); + }); + + const fullCounts = { + maxPhotos: 10, + maxVideos: 3, + photos: 10, + videos: 3, + completionPhotos: 10, + completionVideos: 3, + }; + const videoDocument: VendorPortalDocument = { + ...quarantinedDocument, + id: 21, + originalFileName: "walkthrough.mp4", + contentType: "video/mp4", + }; + + it("rejects the 4th video and 11th photo counted across the whole work order", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderWithProviders( + , + { withAuth: false }, + ); + + choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File); + expect(await screen.findByRole("alert")).toHaveTextContent( + "A work order can have at most 3 videos.", + ); + choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "A work order can have at most 10 photos.", + ), + ); + expect(upload).not.toHaveBeenCalled(); + }); + + it("lets a vendor replace one of the counted videos on a full work order", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File; + choose(replacement); + + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 }); + }); + }); + it("reports upload progress and refreshes the dispatch after success", async () => { const onChanged = vi.fn().mockResolvedValue(undefined); let finishUpload: (() => void) | undefined; @@ -97,4 +256,40 @@ describe("VendorPortalDocuments", () => { finishUpload?.(); await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); }); + + it("drops an earlier pick whose video check finishes after a newer pick", async () => { + let finishProbe: ((seconds: number) => void) | undefined; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve; + }), + ); + const upload = vi + .spyOn(vendorPortalApi, "uploadDocument") + .mockResolvedValue(quarantinedDocument); + renderWithProviders( + , + { withAuth: false }, + ); + const input = screen.getByLabelText("Choose completion document"); + const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File; + const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File; + + fireEvent.change(input, { target: { files: [video] } }); + await waitFor(() => expect(finishProbe).toBeDefined()); + fireEvent.change(input, { target: { files: [photo] } }); + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + finishProbe?.(30); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls[0][0].file).toBe(photo); + }); }); diff --git a/src/test/components/auth/password-checklist.test.tsx b/src/test/components/auth/password-checklist.test.tsx new file mode 100644 index 00000000..2b7bc6dc --- /dev/null +++ b/src/test/components/auth/password-checklist.test.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { PasswordChecklist } from "@/components/auth/password-checklist"; + +function ChecklistHarness() { + const [password, setPassword] = useState(""); + return ( + <> + + setPassword(event.target.value)} /> + + + ); +} + +function ruleStates(): Record { + const list = screen.getByRole("list", { name: "Password requirements" }); + return Object.fromEntries( + within(list) + .getAllByRole("listitem") + .map((item) => [item.getAttribute("data-rule") ?? "", item.getAttribute("data-met")]), + ); +} + +describe("PasswordChecklist", () => { + it("starts with every rule unmet", () => { + render(); + + expect(ruleStates()).toEqual({ + length: "false", + uppercase: "false", + number: "false", + special: "false", + }); + expect(screen.getAllByText("requirement not met")).toHaveLength(4); + }); + + it("marks each rule met live as the user types", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Password"); + + await user.type(input, "A"); + expect(ruleStates()).toMatchObject({ uppercase: "true", number: "false", special: "false" }); + + await user.type(input, "1"); + expect(ruleStates()).toMatchObject({ uppercase: "true", number: "true", special: "false" }); + + await user.type(input, "!"); + expect(ruleStates()).toMatchObject({ special: "true", length: "false" }); + + await user.type(input, "xyz"); + expect(ruleStates()).toEqual({ + length: "true", + uppercase: "true", + number: "true", + special: "true", + }); + expect(screen.getAllByText("requirement met")).toHaveLength(4); + }); + + it("announces only the rules whose state changed through a polite status region", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Password"); + const status = screen.getByRole("status"); + + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent(/^$/); + + await user.type(input, "A"); + expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/); + + await user.type(input, "b"); + expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/); + + await user.type(input, "1!xy"); + expect(status).toHaveTextContent(/^At least 6 characters: requirement met$/); + + await user.clear(input); + expect(status).toHaveTextContent( + "At least 6 characters: requirement not met. One uppercase letter: requirement not met. One number: requirement not met. One special character: requirement not met", + ); + }); + + it("marks a rule unmet again when the user deletes the character that satisfied it", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Password"); + + await user.type(input, "Abcde1!"); + await user.type(input, "{Backspace}"); + + expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" }); + }); +}); diff --git a/src/test/components/layout/app-sidebar-can-approve.test.tsx b/src/test/components/layout/app-sidebar-can-approve.test.tsx new file mode 100644 index 00000000..9db93295 --- /dev/null +++ b/src/test/components/layout/app-sidebar-can-approve.test.tsx @@ -0,0 +1,58 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { createTestQueryClient, renderWithProviders } from "@/test/test-utils"; + +const canApproveApiMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/domain/uplifts/api/uplifts-api", () => ({ + upliftsApi: { canApprove: canApproveApiMock }, +})); + +describe("AppSidebar Uplift Approvals gating through the can-approve query", () => { + beforeEach(() => { + canApproveApiMock.mockReset(); + }); + + it("shows the entry when the API allows tier 2 and fails for tier 1", async () => { + canApproveApiMock.mockImplementation(async (tier: number) => { + if (tier === 1) throw new Error("503"); + return true; + }); + + renderWithProviders(, { route: "/", withAuth: false }); + + expect(await screen.findByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( + "href", + "/uplifts", + ); + expect(canApproveApiMock).toHaveBeenCalledWith(1); + expect(canApproveApiMock).toHaveBeenCalledWith(2); + }); + + it("keeps the entry hidden once the API has denied both tiers", async () => { + canApproveApiMock.mockResolvedValue(false); + const queryClient = createTestQueryClient(); + + renderWithProviders(, { + route: "/", + withAuth: false, + queryClient, + }); + + await waitFor(() => { + for (const tier of [1, 2]) { + expect(queryClient.getQueryState(queryKeys.uplifts.canApprove(tier))?.status).toBe( + "success", + ); + } + }); + expect(screen.queryByRole("link", { name: "Uplift Approvals" })).toBeNull(); + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute( + "href", + "/settings/services", + ); + }); +}); diff --git a/src/test/components/layout/app-sidebar-nav.test.tsx b/src/test/components/layout/app-sidebar-nav.test.tsx new file mode 100644 index 00000000..7565d3e7 --- /dev/null +++ b/src/test/components/layout/app-sidebar-nav.test.tsx @@ -0,0 +1,126 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { renderWithProviders } from "@/test/test-utils"; + +const canApproveMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: canApproveMock, +})); + +function renderSidebar(route: string) { + return renderWithProviders(, { + route, + withAuth: false, + }); +} + +function sidebarLinks(): Record { + const nav = document.getElementById("primary-navigation"); + const links = Array.from(within(nav as HTMLElement).getAllByRole("link")); + return Object.fromEntries( + links.map((link) => [link.textContent ?? "", link.getAttribute("href") ?? ""]), + ); +} + +describe("AppSidebar navigation entries", () => { + it("links every signed-in user to My Account and marks it current on that page", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/account"); + const accountNav = screen.getByRole("navigation", { name: "Account navigation" }); + const link = within(accountNav).getByRole("link", { name: "My Account" }); + + expect(link).toHaveAttribute("href", "/account"); + expect(link).toHaveAttribute("aria-current", "page"); + }); + + it("shows Uplift Approvals for a user the backend says can approve uplifts", () => { + canApproveMock.mockReturnValue({ data: true }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBe("/uplifts"); + }); + + it("hides Uplift Approvals when the backend says the user cannot approve uplifts", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("hides Uplift Approvals while the backend answer is still loading", () => { + canApproveMock.mockReturnValue({ data: undefined }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it.each([ + [1, "tier 1"], + [2, "tier 2"], + ])("shows Uplift Approvals when only %s approval is allowed (%s)", (allowedTier) => { + canApproveMock.mockImplementation((tier: number) => ({ data: tier === allowedTier })); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBe("/uplifts"); + expect(canApproveMock).toHaveBeenCalledWith(1); + expect(canApproveMock).toHaveBeenCalledWith(2); + }); + + it("hides Uplift Approvals when the can-approve request fails", () => { + canApproveMock.mockReturnValue({ data: undefined, isError: true, error: new Error("403") }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("hides Uplift Approvals when a refetch fails after an earlier approval", () => { + canApproveMock.mockReturnValue({ data: true, isError: true, error: new Error("403") }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("offers Services to every user", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Services"]).toBe("/settings/services"); + }); + + it("highlights the Uplift Approvals entry on /uplifts", () => { + canApproveMock.mockReturnValue({ data: true }); + + renderSidebar("/uplifts"); + + expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("highlights the Services entry on /settings/services", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/settings/services"); + + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page"); + }); +}); diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx index 781ee098..8e169f44 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti import { renderWithProviders } from "@/test/test-utils"; const apiGet = vi.hoisted(() => vi.fn()); +const apiPostNoContent = vi.hoisted(() => vi.fn()); -vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent })); vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), @@ -131,6 +132,8 @@ describe("notification center", () => { beforeEach(() => { apiGet.mockReset(); apiGet.mockResolvedValue(standardFeed); + apiPostNoContent.mockReset(); + apiPostNoContent.mockResolvedValue(undefined); }); it("requests the signed-in user's feed and counts every unread item on the bell", async () => { @@ -256,6 +259,58 @@ describe("notification center", () => { expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); }); + it("keeps Clear all disabled when only rows that need acknowledging are left", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + + expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled(); + }); + + it("acknowledges a missed deadline through the API and removes the row", async () => { + apiGet.mockResolvedValue( + feed( + section("sla", "SLA at Risk", [acknowledgeRow]), + section("unassigned", "Unassigned", [unassigned]), + ), + ); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement; + const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" }); + expect(acknowledge).toHaveAttribute("title", "Acknowledge"); + expect(acknowledge).toHaveTextContent("Acknowledge"); + + apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned]))); + await user.click(acknowledge); + + expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge"); + await waitFor(() => + expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(), + ); + expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]); + }); + + it("keeps the row when the acknowledgement fails", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + apiPostNoContent.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })); + + await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(), + ); + expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); + }); + it("opens the unassigned queue with the existing board filters and closes the panel", async () => { const user = userEvent.setup(); renderBell(); diff --git a/src/test/components/notifications/sla-at-risk-banner.test.tsx b/src/test/components/notifications/sla-at-risk-banner.test.tsx new file mode 100644 index 00000000..163b36b9 --- /dev/null +++ b/src/test/components/notifications/sla-at-risk-banner.test.tsx @@ -0,0 +1,148 @@ +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const apiGet = vi.hoisted(() => vi.fn()); +const toastWarn = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } })); + +const BANNER = "Work orders at risk of missing their response deadline"; + +function atRisk(id: number, number: string | null = String(1000 + id)) { + return { + id, + number, + severity: 1, + startedAt: "2026-09-18T10:00:00Z", + deadlineAt: "2026-09-18T12:00:00Z", + percentElapsed: 60, + }; +} + +function feed(...slaAtRisk: ReturnType[]) { + return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk }; +} + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderBanner() { + return renderWithProviders( + + + + , + { withAuth: false }, + ); +} + +describe("SLA at-risk banner", () => { + beforeEach(() => { + apiGet.mockReset(); + toastWarn.mockReset(); + }); + + it("stays hidden while no work order is past half of its response window", async () => { + apiGet.mockResolvedValue(feed()); + renderBanner(); + + await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications")); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + expect(toastWarn).not.toHaveBeenCalled(); + }); + + it("names the single work order at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101))); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + expect( + within(banner).getByRole("button", { + name: "WO #1101 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + }); + + it("collapses several work orders to one line and opens Work Orders on exactly those", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + const user = userEvent.setup(); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + const open = within(banner).getByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + expect(open).toHaveAttribute("title", "View in Work Orders"); + expect(within(banner).getAllByRole("button")).toHaveLength(2); + + await user.click(open); + + expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( + "/workorders?ids=101,205,318", + ); + }); + + it("dismisses for this session only and returns when a new work order becomes at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205))); + const user = userEvent.setup(); + const { queryClient, unmount } = renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" })); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + const returned = await screen.findByRole("region", { name: BANNER }); + expect( + within(returned).getByRole("button", { + name: "WO #1318 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + + unmount(); + renderBanner(); + expect( + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }), + ).toBeInTheDocument(); + }); + + it("toasts each work order once per session, however often the feed polls", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null))); + const { queryClient } = renderBanner(); + + await screen.findByRole("region", { name: BANNER }); + expect(toastWarn).toHaveBeenCalledTimes(2); + expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", { + toastId: "sla-at-risk-101", + }); + expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", { + toastId: "sla-at-risk-205", + }); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + expect(toastWarn).toHaveBeenCalledTimes(3); + expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", { + toastId: "sla-at-risk-318", + }); + }); +}); diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts index ac75b38c..63ee8865 100644 --- a/src/test/config/menu.test.ts +++ b/src/test/config/menu.test.ts @@ -1,4 +1,4 @@ -import { getActiveNavPath } from "@/config/menu"; +import { getActiveNavPath, getNavGroups, navGroups } from "@/config/menu"; import { describe, expect, it } from "vitest"; describe("getActiveNavPath", () => { @@ -10,6 +10,9 @@ describe("getActiveNavPath", () => { ["/vendors/insights", "/vendors/insights"], ["/vendors/preferences", "/vendors/preferences"], ["/vendor-pos/42", "/vendor-pos"], + ["/settings/services", "/settings/services"], + ["/uplifts", "/uplifts"], + ["/uplifts/12", "/uplifts"], ])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => { expect(getActiveNavPath(pathname)).toBe(expectedPath); }); @@ -17,4 +20,48 @@ describe("getActiveNavPath", () => { it("does not match partial route segments", () => { expect(getActiveNavPath("/vendor-posts")).toBeUndefined(); }); + + it("does not highlight the Services entry from other settings tabs", () => { + expect(getActiveNavPath("/settings/dropdowns")).toBeUndefined(); + }); +}); + +describe("navGroups", () => { + it("always offers Services in a DATA MANAGEMENT group", () => { + const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT"); + expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services"); + }); + + it("lists Completion Docs after Services in the DATA MANAGEMENT group", () => { + const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT"); + expect(dataManagement?.items.map((item) => item.label)).toEqual([ + "Services", + "Completion Docs", + ]); + expect(getActiveNavPath("/completion-docs")).toBe("/completion-docs"); + }); + + it("does not include the approvals group by default", () => { + expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false); + }); +}); + +describe("getNavGroups", () => { + it("adds the APPROVALS group with Uplift Approvals for uplift reviewers", () => { + const groups = getNavGroups({ canReviewUplifts: true }); + const approvals = groups.find((group) => group.title === "APPROVALS"); + expect(approvals?.items.map((item) => item.path)).toEqual(["/uplifts"]); + expect(approvals?.items[0]?.label).toBe("Uplift Approvals"); + }); + + it("omits the APPROVALS group for users who cannot review uplifts", () => { + const groups = getNavGroups({ canReviewUplifts: false }); + expect(groups.some((group) => group.title === "APPROVALS")).toBe(false); + }); + + it("keeps the approvals group after TEAM", () => { + const groups = getNavGroups({ canReviewUplifts: true }); + const titles = groups.map((group) => group.title); + expect(titles.indexOf("TEAM")).toBeLessThan(titles.indexOf("APPROVALS")); + }); }); diff --git a/src/test/domain/auth/api/auth-api.test.ts b/src/test/domain/auth/api/auth-api.test.ts new file mode 100644 index 00000000..498d28be --- /dev/null +++ b/src/test/domain/auth/api/auth-api.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiPost = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiPost: (...args: unknown[]) => apiPost(...args), +})); + +import { forgotPassword, resetPassword, verifyResetCode } from "@/domain/auth/api/auth-api"; + +describe("password reset API", () => { + beforeEach(() => { + apiPost.mockReset(); + apiPost.mockResolvedValue({ status: "Success ", message: "ok" }); + }); + + it("sends the email in the request body, never in the URL", async () => { + await forgotPassword("alice@example.com"); + + expect(apiPost).toHaveBeenCalledWith("Authentication/ForgetPassword", { + email: "alice@example.com", + }); + expect(String(apiPost.mock.calls[0][0])).not.toContain("?"); + }); + + it("verifies a code together with the email it was issued to", async () => { + await verifyResetCode("alice@example.com", "123456"); + + expect(apiPost).toHaveBeenCalledWith("Authentication/VerificationCode", { + email: "alice@example.com", + code: "123456", + }); + expect(String(apiPost.mock.calls[0][0])).not.toContain("123456"); + }); + + it("resets with email, code and the new password in the body", async () => { + await resetPassword({ Email: "alice@example.com", Code: "123456", Password: "New@67890" }); + + expect(apiPost).toHaveBeenCalledWith("Authentication/ResetPassword", { + Email: "alice@example.com", + Code: "123456", + Password: "New@67890", + }); + }); +}); diff --git a/src/test/domain/auth/password-policy.test.ts b/src/test/domain/auth/password-policy.test.ts new file mode 100644 index 00000000..486f925a --- /dev/null +++ b/src/test/domain/auth/password-policy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + PASSWORD_RULES, + evaluatePasswordRules, + meetsPasswordPolicy, + passwordSchema, +} from "@/domain/auth/password-policy"; + +function unmetRules(password: string): string[] { + return evaluatePasswordRules(password) + .filter((rule) => !rule.met) + .map((rule) => rule.id); +} + +describe("password policy", () => { + it("lists the four rules in checklist order with the shared copy", () => { + expect(PASSWORD_RULES.map((rule) => rule.label)).toEqual([ + "At least 6 characters", + "One uppercase letter", + "One number", + "One special character", + ]); + }); + + it.each([ + ["Ab1!x", "length"], + ["abc12!", "uppercase"], + ["Abcde!", "number"], + ["Abcde1", "special"], + ])("rejects %s because only the %s rule is missing", (password, missingRule) => { + expect(unmetRules(password)).toEqual([missingRule]); + expect(meetsPasswordPolicy(password)).toBe(false); + expect(passwordSchema.safeParse(password).success).toBe(false); + }); + + it.each(["Abc1!x", "ABC12!"])("accepts the six-character compliant password %s", (password) => { + expect(unmetRules(password)).toEqual([]); + expect(meetsPasswordPolicy(password)).toBe(true); + expect(passwordSchema.safeParse(password).success).toBe(true); + }); + + it("treats a non-ASCII letter as a special character, as the server does", () => { + expect(unmetRules("Abcdé1")).toEqual([]); + }); +}); diff --git a/src/test/domain/completion-templates/completion-template-mapper.test.ts b/src/test/domain/completion-templates/completion-template-mapper.test.ts new file mode 100644 index 00000000..b1c9966a --- /dev/null +++ b/src/test/domain/completion-templates/completion-template-mapper.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + mapCompletionTemplate, + toCompletionTemplateInput, +} from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { completionTemplateDtoSchema } from "@/domain/completion-templates/schemas/completion-template-schema"; + +describe("mapCompletionTemplate", () => { + it("orders procedures by sortOrder and shows createdAt when never updated", () => { + const template = mapCompletionTemplate( + completionTemplateDtoSchema.parse({ + id: 4, + name: "PRV", + extraSafetyNote: " ", + procedures: [ + { id: 2, name: "Second", description: "b", sortOrder: 1 }, + { id: 1, name: "First", description: "a", sortOrder: 0 }, + ], + createdAt: "2026-09-01T00:00:00Z", + updatedAt: null, + createdByName: null, + }), + ); + + expect(template.procedures.map((procedure) => procedure.name)).toEqual(["First", "Second"]); + expect(template.lastUpdatedAt).toBe("2026-09-01T00:00:00Z"); + expect(template.extraSafetyNote).toBeNull(); + expect(template.createdByName).toBeNull(); + }); + + it("rejects a response that is not a template", () => { + expect(completionTemplateDtoSchema.safeParse({ name: "no id" }).success).toBe(false); + }); +}); + +describe("toCompletionTemplateInput", () => { + it("trims fields, clears a blank note and drops procedures left entirely blank", () => { + expect( + toCompletionTemplateInput({ + name: " Title ", + extraSafetyNote: " ", + procedures: [ + { name: " ", description: "" }, + { name: "", description: " steps " }, + ], + }), + ).toEqual({ + name: "Title", + extraSafetyNote: null, + procedures: [{ name: "", description: "steps" }], + }); + }); +}); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts index 95f8c258..27a87695 100644 --- a/src/test/domain/dashboard/dashboard-api.test.ts +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -27,6 +27,7 @@ describe("dashboardApi", () => { ScheduledTomorrow: "3", PendingUplifts: 2, AvetaPending: "1", + Unassigned: "6", Breakdown: [ { Status: "Scheduled", Count: "7" }, { Status: "Completed", Count: 4 }, @@ -52,6 +53,7 @@ describe("dashboardApi", () => { scheduledTomorrow: 3, pendingUplifts: 2, avetaPending: 1, + unassigned: 6, breakdown: [ { status: "Scheduled", count: 7 }, { status: "Completed", count: 4 }, diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts index 52d27cc6..cd8734ce 100644 --- a/src/test/domain/locations/mappers/location-mapper.test.ts +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -3,6 +3,7 @@ import { mapLocation, mapLocationListItem, mapLocationToBackend, + mapSiteOpenWorkOrders, } from "@/domain/locations/mappers/location-mapper"; describe("location contacts hydration", () => { @@ -56,16 +57,15 @@ describe("location contacts hydration", () => { }); }); -describe("mapLocationToBackend contacts payload", () => { - it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { +describe("mapLocationToBackend site payload", () => { + it("sends trimmed contacts in displayed order and names the first as the legacy contact", () => { const payload = mapLocationToBackend({ - name: "Dallas DC", + name: " dal1 ", address: "3811 Distribution Dr", city: "Dallas", state: "TX", zipCode: "70068", contactEmail: "jane@example.com", - status: "Active", contacts: [ { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, { name: "Bob", phone: "(421) 433-0033" }, @@ -77,18 +77,87 @@ describe("mapLocationToBackend contacts payload", () => { { name: "Bob", phone: "(421) 433-0033" }, ]); expect(payload.contact).toBe("Jane"); - expect(payload.phone).toBe("(421) 433-0032"); + expect(payload.name).toBe("dal1"); }); - it("keeps legacy contact/phone untouched when no contacts are provided", () => { + it("sends the Site Phone as phone, never the main contact's phone", () => { const payload = mapLocationToBackend({ - name: "Legacy DC", - contact: "Legacy Person", - phone: "555-0199", + sitePhone: " (214) 555-0100 ", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + const withoutSitePhone = mapLocationToBackend({ + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], }); - expect(payload.contact).toBe("Legacy Person"); - expect(payload.phone).toBe("555-0199"); - expect(payload.contacts).toEqual([]); + expect(payload.phone).toBe("(214) 555-0100"); + expect(withoutSitePhone.phone).toBe(""); + }); + + it("omits status, title and notes unless given, so an update keeps the stored values", () => { + const bare = mapLocationToBackend({ name: "DAL1", contacts: [] }); + const full = mapLocationToBackend({ + name: "DAL1", + status: "Active", + title: "Dallas DC", + notes: "Gate code 1234", + contacts: [], + }); + + expect(bare).not.toHaveProperty("status"); + expect(bare).not.toHaveProperty("title"); + expect(bare).not.toHaveProperty("notes"); + expect(full).toMatchObject({ status: "Active", title: "Dallas DC", notes: "Gate code 1234" }); + }); + + it("keeps the legacy contact name when the caller sends no contacts list", () => { + const addressOnly = mapLocationToBackend({ + name: "DAL1", + address: "3811 Distribution Dr", + contact: "Jane Doe", + }); + const withContacts = mapLocationToBackend({ + contact: "Jane Doe", + contacts: [{ name: "Bob", phone: "(421) 433-0033" }], + }); + + expect(addressOnly.contact).toBe("Jane Doe"); + expect(withContacts.contact).toBe("Bob"); + }); +}); + +describe("mapLocation site fields", () => { + it("reads client name, site phone, title and notes separately from the main contact", () => { + const detail = mapLocation({ + id: 9, + name: "DAL1", + title: "Dallas DC", + clientName: "Acme", + phone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + const row = mapLocationListItem({ id: 9, accountName: "Acme", phone: "(214) 555-0100" }); + + expect(detail).toMatchObject({ + clientName: "Acme", + sitePhone: "(214) 555-0100", + title: "Dallas DC", + notes: "Gate code 1234", + phone: "555-0143", + }); + expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" }); + }); +}); + +describe("mapSiteOpenWorkOrders", () => { + it("keeps the server count and drops invalid ids", () => { + expect(mapSiteOpenWorkOrders({ count: 240, workOrderIds: [101, "x", -3, 205] })).toEqual({ + count: 240, + workOrderIds: [101, 205], + }); + expect(mapSiteOpenWorkOrders({ Count: 0, WorkOrderIds: [] })).toEqual({ + count: 0, + workOrderIds: [], + }); }); }); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts index ecf04b56..859a0592 100644 --- a/src/test/domain/locations/schemas/location-schema.test.ts +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -7,7 +7,8 @@ import { const validBase: LocationFormValues = { ...locationDefaultValues, - name: "Dallas DC", + name: "DAL1", + accountId: "7", address: "3811 Distribution Dr", city: "Dallas", state: "TX", @@ -46,8 +47,8 @@ describe("locationSchema contacts", () => { expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((issue) => issue.message); - expect(messages).toContain("Phone is required"); - expect(messages).toContain("Contact person is required"); + expect(messages).toContain("Contact phone is required"); + expect(messages).toContain("Contact name is required"); } }); @@ -63,3 +64,41 @@ describe("locationSchema contacts", () => { } }); }); + +describe("locationSchema site fields", () => { + const validSite: LocationFormValues = { + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }; + + function messagesFor(values: LocationFormValues): string[] { + const result = locationSchema.safeParse(values); + return result.success ? [] : result.error.issues.map((issue) => issue.message); + } + + it("requires Site Code, Client, Street Address, City, State and the first contact", () => { + const messages = messagesFor({ ...locationDefaultValues }); + + expect(messages).toEqual( + expect.arrayContaining([ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]), + ); + }); + + it("keeps Site Phone, Site Email and zip optional but checks the email format", () => { + expect( + locationSchema.safeParse({ ...validSite, sitePhone: "", contactEmail: "", zipCode: "" }) + .success, + ).toBe(true); + expect(messagesFor({ ...validSite, contactEmail: "not-an-email" })).toContain( + "Invalid email address", + ); + }); +}); diff --git a/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts new file mode 100644 index 00000000..5349faa8 --- /dev/null +++ b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; + +function row( + overrides: Partial & Pick, +): NotificationItem { + return { + reason: "sla", + severity: "Critical", + rowType: "acknowledge", + title: "missed its response deadline", + count: 1, + triggeredAt: "2026-09-18T12:00:00Z", + target: { kind: "workOrder", workOrderId: 9, tab: "info" }, + workOrders: [], + ...overrides, + }; +} + +const breach9 = row({ id: "sla-breach-9" }); +const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } }); +const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" }); +const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" }); + +const feed: NotificationFeed = { + generatedAt: "2026-09-18T12:00:00Z", + slaAtRisk: [], + sections: [ + { + reason: "sla", + label: "SLA at Risk", + severity: "Critical", + count: 3, + items: [breach9, breach10, atRisk9], + }, + { + reason: "aveta-missing", + label: "Aveta Missing", + severity: "Medium", + count: 1, + items: [aveta9], + }, + ], +}; + +describe("withoutSlaBreach", () => { + it("drops only the acknowledged work order's breach row and lowers its section count", () => { + const next = withoutSlaBreach(feed, 9); + + expect(next.sections[0].items.map((item) => item.id)).toEqual([ + "sla-breach-10", + "sla-at-risk-9", + ]); + expect(next.sections[0].count).toBe(2); + expect(next.sections[1]).toBe(feed.sections[1]); + }); + + it("leaves the feed as it was when that work order has no breach row", () => { + const next = withoutSlaBreach(feed, 42); + + expect(next.sections).toEqual(feed.sections); + expect(next.sections[0]).toBe(feed.sections[0]); + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts index be46e93b..7dc0b1b0 100644 --- a/src/test/domain/services/api/services-api.test.ts +++ b/src/test/domain/services/api/services-api.test.ts @@ -78,11 +78,14 @@ describe("servicesApi", () => { }); }); - it("does not request services for the unsupported Overdue type", async () => { - const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + it("lists the PM catalog for the Overdue type", async () => { + vi.mocked(apiGet).mockResolvedValue([]); - expect(apiGet).not.toHaveBeenCalled(); - expect(services).toEqual([]); + await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); }); it("creates, updates, and deactivates through the registry routes", async () => { diff --git a/src/test/domain/team-members/invite-calls-without-body.test.ts b/src/test/domain/team-members/invite-calls-without-body.test.ts new file mode 100644 index 00000000..83986c95 --- /dev/null +++ b/src/test/domain/team-members/invite-calls-without-body.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; + +/** The server has already acted when it answers, so an empty success body must still succeed. */ +describe("invite calls that return nothing", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([200, 204])("treat an empty %i response as success", async (status) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status })); + + await expect(teamMembersApi.resendInvite("user-7")).resolves.toBeUndefined(); + await expect(teamMemberInviteApi.sendCode("token")).resolves.toBeUndefined(); + await expect( + teamMemberInviteApi.verifyCode({ token: "token", code: "246810" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts index 164a2ef7..9073f169 100644 --- a/src/test/domain/team-members/team-member-mapper.test.ts +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -54,4 +54,10 @@ describe("mapTeamMemberDetail", () => { ], }); }); + + it("keeps the invite email result only when the update reported one", () => { + expect(mapTeamMemberDetail({ id: "u1", inviteEmailSent: false }).inviteEmailSent).toBe(false); + expect(mapTeamMemberDetail({ Id: "u1", InviteEmailSent: true }).inviteEmailSent).toBe(true); + expect(mapTeamMemberDetail({ id: "u1" })).not.toHaveProperty("inviteEmailSent"); + }); }); diff --git a/src/test/domain/team-members/use-create-team-member.test.tsx b/src/test/domain/team-members/use-create-team-member.test.tsx new file mode 100644 index 00000000..a18564b6 --- /dev/null +++ b/src/test/domain/team-members/use-create-team-member.test.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + CreateTeamMemberInput, + TeamMemberCreated, +} from "@/domain/team-members/types/team-member"; +import { + INVITE_EMAIL_FAILED_MESSAGE, + useCreateTeamMember, +} from "@/domain/team-members/use-cases/use-create-team-member"; + +vi.mock("react-toastify", () => ({ + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/domain/team-members/api/team-members-api", () => ({ + teamMembersApi: { create: vi.fn() }, +})); + +const CREATED: TeamMemberCreated = { + id: "user-7", + name: "Taylor Reed", + role: "Dispatcher", + color: "#0D9488", + email: "taylor@example.com", + phone: "555-0100", + serviceAreas: ["East"], + pendingRegistration: true, +}; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return {children}; +} + +async function create(created: TeamMemberCreated) { + vi.mocked(teamMembersApi.create).mockResolvedValue(created); + const { result } = renderHook(() => useCreateTeamMember(), { wrapper }); + act(() => result.current.mutate({} as CreateTeamMemberInput)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); +} + +describe("useCreateTeamMember", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("confirms the pending member when the invite email went out", async () => { + await create({ ...CREATED, inviteEmailSent: true }); + + expect(toast.success).toHaveBeenCalledWith("Team member created with pending registration."); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("warns the admin to re-send when the invite email could not be sent", async () => { + await create({ ...CREATED, inviteEmailSent: false }); + + expect(toast.warning).toHaveBeenCalledWith(INVITE_EMAIL_FAILED_MESSAGE); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/team-members/use-update-team-member.test.tsx b/src/test/domain/team-members/use-update-team-member.test.tsx new file mode 100644 index 00000000..aa7cc5a4 --- /dev/null +++ b/src/test/domain/team-members/use-update-team-member.test.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + TeamMemberDetail, + UpdateTeamMemberInput, +} from "@/domain/team-members/types/team-member"; +import { + UPDATED_INVITE_EMAIL_FAILED_MESSAGE, + useUpdateTeamMember, +} from "@/domain/team-members/use-cases/use-team-member-detail"; + +vi.mock("react-toastify", () => ({ + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/domain/team-members/api/team-members-api", () => ({ + teamMembersApi: { update: vi.fn() }, +})); + +const UPDATED = { + id: "user-7", + name: "Taylor Reed", + email: "taylor.reed@example.com", + pendingRegistration: true, + serviceAreas: ["East"], + permissions: [], +} as unknown as TeamMemberDetail; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return {children}; +} + +async function update(updated: TeamMemberDetail) { + vi.mocked(teamMembersApi.update).mockResolvedValue(updated); + const { result } = renderHook(() => useUpdateTeamMember(), { wrapper }); + act(() => result.current.mutate({ id: "user-7", input: {} as UpdateTeamMemberInput })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); +} + +describe("useUpdateTeamMember", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("confirms an ordinary update", async () => { + await update(UPDATED); + + expect(toast.success).toHaveBeenCalledWith("Team member updated."); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("tells the admin a new invite went to the corrected email", async () => { + await update({ ...UPDATED, inviteEmailSent: true }); + + expect(toast.success).toHaveBeenCalledWith( + "Team member updated. A new invite was sent to taylor.reed@example.com.", + ); + }); + + it("warns the admin to re-send when the new invite could not be emailed", async () => { + await update({ ...UPDATED, inviteEmailSent: false }); + + expect(toast.warning).toHaveBeenCalledWith(UPDATED_INVITE_EMAIL_FAILED_MESSAGE); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx new file mode 100644 index 00000000..2296c47a --- /dev/null +++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx @@ -0,0 +1,38 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error, success: vi.fn() }, +})); + +afterEach(() => { + vi.restoreAllMocks(); + toastMocks.error.mockReset(); +}); + +describe("useUpliftsQueue", () => { + it("does not show the global error toast, since the approvals page reports the failure inline", async () => { + vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue")); + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + + const { result } = renderHook( + () => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 0bf46b4a..f935c556 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -420,6 +420,14 @@ describe("workOrdersApi.searchBoard", () => { }); }); +/** A WO# lookup spans every week and undated rows. */ +const ALL_WEEKS_SEARCH = { + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: "true", +}; + describe("workOrdersApi.findByWoNumber", () => { beforeEach(() => { apiGet.mockReset(); @@ -450,6 +458,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "10000001235", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); @@ -519,6 +528,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "9", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts index c9c16563..88a45464 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from "vitest"; -import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; -import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { + buildSiteOpenWorkOrdersHref, + parseDashboardDrilldownParams, +} from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { + avetaPendingDrilldownSearch, + unassignedDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params"; describe("parseDashboardDrilldownParams", () => { it("returns null when no recognized param is present", () => { @@ -60,6 +71,74 @@ describe("parseDashboardDrilldownParams", () => { expect(filters?.regions).toEqual(new Set(["California"])); }); + it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => { + const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time")); + expect(allTime).not.toBeNull(); + expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({ + dispatchers: ["__unassigned__"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + }); + + const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week")); + const weekParams = toBoardSearchQueryParams({ filters: week! }); + expect(week?.dateRange).toBe("custom"); + expect(weekParams).toMatchObject({ + dispatchers: ["__unassigned__"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + }); + expect(weekParams.includeDateless).toBeUndefined(); + }); + + it("opens the Unassigned tile link as the Unassigned option of Assigned to", () => { + const filters = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week")); + expect(filters?.dispatchers).toEqual(new Set([ASSIGNEE_FILTER_UNASSIGNED])); + + const chips = buildAdvancedFilterChips(filters!, { dispatcherNameOf: (id) => id }); + const assignedTo = chips.filter((chip) => chip.group === "Assigned to"); + expect(assignedTo.map((chip) => chip.value)).toEqual(["Unassigned"]); + + const cleared = assignedTo[0]!.clear(filters!); + expect(cleared.dispatchers.size).toBe(0); + }); + + it("turns the site open work-order link into that site's open, all-weeks board search", () => { + const href = buildSiteOpenWorkOrdersHref(12); + const filters = parseDashboardDrilldownParams(new URL(href, "https://app.test").searchParams); + + expect(filters?.sites).toEqual(new Set(["12"])); + expect(filters?.workOrderIds.size).toBe(0); + const params = buildResolvedBoardSearchParams({ + advApplied: filters, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [{ id: 12, name: "DAL1", siteCode: "DAL1" }], + vendors: [], + }); + expect(params).toMatchObject({ + sites: ["DAL1"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + }); + }); + + it("keeps an exact id set ahead of a site filter", () => { + const filters = parseDashboardDrilldownParams(new URLSearchParams({ ids: "4,9", sites: "12" })); + + expect(filters?.workOrderIds).toEqual(new Set([4, 9])); + expect(filters?.sites.size).toBe(0); + }); + it("rejects partial or malformed date ranges", () => { expect( parseDashboardDrilldownParams( diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts index 1820df72..d8a3c17d 100644 --- a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts +++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts @@ -16,6 +16,11 @@ describe("COMPLETED DATE board column wiring", () => { expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]); }); + it("expands a clearing completedDate patch to an empty-string board field op (SH-390)", () => { + const ops = expandScalarBoardOps({ completedDate: "" }); + expect(ops).toEqual([{ field: "completedDate", value: "" }]); + }); + it("maps completedDate returned by a board GET row", () => { const row = mapWorkOrderTableRow({ id: 42, diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index e5d0cd0e..b82d970c 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); + expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue); + + expect(WORK_ORDER_TYPE_API.Overdue).toBe(8); expect(mapWizardTypeToApi("")).toBeNull(); }); diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index e26229d7..f065e4e0 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -40,12 +40,12 @@ describe("countAdvancedFilters", () => { expect(countAdvancedFilters(filters)).toBe(3); }); - it("counts Unscheduled +1 with the this-week default still in place", () => { + it("counts Unscheduled +1 with no date range selected", () => { const filters = { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), unscheduledOnly: true, }; - expect(filters.dateRange).toBe("this-week"); + expect(filters.dateRange).toBeNull(); expect(countAdvancedFilters(filters)).toBe(1); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 486cd60e..39fa0275 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => { expect(params.overdue).toBeUndefined(); }); - it("maps Overdue bar filter to overdue=true without types", () => { + it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps Overdue with other types as a plain type union", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); - expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); - expect(params.overdue).toBe(true); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); it("omits types when the set is empty", () => { @@ -130,9 +130,43 @@ describe("toBoardSearchQueryParams", () => { customTo: "2026-06-30", }, }); - expect(thisWeek.datePreset).toBe("Custom"); - expect(thisWeek.dateFrom).toBe("2000-01-01"); - expect(thisWeek.dateTo).toBe("2099-12-31"); + expect(thisWeek.datePreset).toBe("ThisWeek"); + expect(thisWeek.dateFrom).toBeUndefined(); + expect(thisWeek.dateTo).toBeUndefined(); + }); + + it("narrows strictly for every selected range, This week included", () => { + for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + dateRange, + customFrom: "2026-09-21", + customTo: "2026-09-25", + dispatchers: new Set(["__unassigned__"]), + }, + }); + expect(params.includeDateless).toBeUndefined(); + expect(params.dateFrom).not.toBe("2000-01-01"); + } + }); + + it("searches every week, undated rows included, when no range is selected", () => { + const params = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) }, + }); + expect(EMPTY_ADVANCED_FILTERS.dateRange).toBeNull(); + expect(params).toMatchObject({ + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + dispatchers: ["__unassigned__"], + }); + }); + + it("keeps undated rows in the pinned Unassigned queue", () => { + expect(toUnassignedQueueQueryParams().includeDateless).toBe(true); }); it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => { @@ -188,22 +222,23 @@ describe("toBoardSearchQueryParams", () => { expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); }); - it("maps Overdue-only advanced filter to overdue=true without types", () => { + it("maps the Overdue type advanced filter to types without the past-due flag", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, types: new Set(["Overdue"]), }, }); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps the Past Due status toggle to overdue=true alongside types", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, - types: new Set(["PM", "Overdue"]), + types: new Set(["PM"]), + pastDue: true, }, }); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); diff --git a/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts new file mode 100644 index 00000000..d33caa6d --- /dev/null +++ b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + a4PageHeight, + coverAllBlocks, + currentPageAt, + pageContentHeight, + pageIndicatorLabel, + paginateBlocks, + samePages, +} from "@/domain/work-orders/utils/completion-doc-pagination"; + +describe("paginateBlocks", () => { + it("keeps a short document on one page", () => { + expect( + paginateBlocks({ headerHeight: 200, blockHeights: [100, 150], pageHeight: 800 }), + ).toEqual([[0, 1]]); + }); + + it("returns one empty page when there are no blocks", () => { + expect(paginateBlocks({ headerHeight: 200, blockHeights: [], pageHeight: 800 })).toEqual([[]]); + }); + + it("moves a block that no longer fits onto the next page, in order", () => { + expect( + paginateBlocks({ headerHeight: 300, blockHeights: [400, 200, 500, 300], pageHeight: 800 }), + ).toEqual([[0], [1, 2], [3]]); + }); + + it("charges the letterhead to the first page only", () => { + expect( + paginateBlocks({ headerHeight: 500, blockHeights: [300, 400, 400], pageHeight: 800 }), + ).toEqual([[0], [1, 2]]); + }); + + it("fills a page exactly without spilling", () => { + expect( + paginateBlocks({ headerHeight: 0, blockHeights: [400, 400, 1], pageHeight: 800 }), + ).toEqual([[0, 1], [2]]); + }); + + it("gives a block taller than a page its own page instead of splitting or hiding it", () => { + expect( + paginateBlocks({ headerHeight: 200, blockHeights: [100, 2000, 100], pageHeight: 800 }), + ).toEqual([[0], [1], [2]]); + }); + + it("starts the first block on page 2 when the letterhead alone fills page 1", () => { + expect(paginateBlocks({ headerHeight: 900, blockHeights: [100], pageHeight: 800 })).toEqual([ + [], + [0], + ]); + }); + + it("keeps an oversized first block on page 1 when there is no letterhead", () => { + expect(paginateBlocks({ headerHeight: 0, blockHeights: [2000, 100], pageHeight: 800 })).toEqual( + [[0], [1]], + ); + }); +}); + +describe("coverAllBlocks", () => { + it("adds blocks created since the last measurement to the last page", () => { + expect(coverAllBlocks([[0], [1]], 4)).toEqual([[0], [1, 2, 3]]); + }); + + it("drops blocks that were removed and the pages they leave empty", () => { + expect(coverAllBlocks([[0], [1, 2], [3]], 2)).toEqual([[0], [1]]); + }); + + it("always keeps the first page, even with no blocks", () => { + expect(coverAllBlocks([[0, 1]], 0)).toEqual([[]]); + expect(coverAllBlocks([], 2)).toEqual([[0, 1]]); + }); +}); + +describe("page geometry", () => { + it("sizes a sheet as A4 portrait and reserves the bars and bottom padding", () => { + expect(a4PageHeight(210)).toBeCloseTo(297); + expect(pageContentHeight(420)).toBeCloseTo(594 - 12 - 24 - 28); + }); +}); + +describe("currentPageAt", () => { + const tops = [0, 900, 1800]; + + it("reads page 1 at the top of the document", () => { + expect(currentPageAt(tops, 0)).toBe(1); + }); + + it("switches page once the next sheet reaches the top of the viewport", () => { + expect(currentPageAt(tops, 880)).toBe(1); + expect(currentPageAt(tops, 890)).toBe(2); + expect(currentPageAt(tops, 2500)).toBe(3); + }); +}); + +describe("pageIndicatorLabel", () => { + it("shows Page X of Y only for multi-page documents", () => { + expect(pageIndicatorLabel({ current: 1, total: 1 })).toBe(""); + expect(pageIndicatorLabel({ current: 2, total: 3 })).toBe(" · Page 2 of 3"); + }); +}); + +describe("samePages", () => { + it("compares page layouts by content", () => { + expect(samePages([[0], [1, 2]], [[0], [1, 2]])).toBe(true); + expect(samePages([[0], [1, 2]], [[0, 1], [2]])).toBe(false); + expect(samePages([[0]], [[0], []])).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index be2211ae..815a7110 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["em", "rx"]); }); - it("matches Overdue via isPastDue even when row.type is not Overdue", () => { + it("matches the Overdue type only, never a past-due row of another type", () => { const pastDuePm = makeRow({ id: "pd", type: "PM", isPastDue: true, scheduledOn: "2026-06-03", }); - const currentPm = makeRow({ - id: "now", - type: "PM", + const overdue = makeRow({ + id: "ov", + type: "Overdue", isPastDue: false, scheduledOn: "2026-06-03", }); const ids = filterByBarAndSearch( - [pastDuePm, currentPm], + [pastDuePm, overdue], bar(weekMonday, { types: new Set(["Overdue"]) }), "", ).map((row) => row.id); - expect(ids).toEqual(["pd"]); + expect(ids).toEqual(["ov"]); }); it("hides a row after type correction when the bar filter no longer matches", () => { @@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(row, noMatch)).toBe(false); }); - it("matches Overdue when isPastDue is true", () => { + it("matches the Overdue type filter by type, independent of isPastDue", () => { const weekMonday = mondayOf(todayIso()); - const pastDue = makeRow({ - scheduledOn: weekMonday, - ...deriveDayFields(weekMonday), - isPastDue: true, - }); const filters = { ...EMPTY_ADVANCED_FILTERS, dateRange: "custom" as const, @@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => { customTo: "9999-12-31", types: new Set(["Overdue" as const]), }; - expect(matchesAdvancedFilters(pastDue, filters)).toBe(true); + const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday }); + const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }); + expect(matchesAdvancedFilters(overdue, filters)).toBe(true); + expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false); + }); + + it("narrows to past-due rows with the Past Due status toggle", () => { + const weekMonday = mondayOf(todayIso()); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "0000-01-01", + customTo: "9999-12-31", + types: new Set(["PM" as const]), + pastDue: true, + }; expect( - matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters), + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), + ).toBe(true); + expect( + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }), + filters, + ), + ).toBe(false); + expect( + matchesAdvancedFilters( + makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), ).toBe(false); }); @@ -454,6 +479,52 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true); expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true); }); + + it("matches a range the same way the board search does", () => { + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "2026-09-23", + customTo: "2026-09-25", + }; + + const weekOnlyStaleDate = makeRow({ + scheduledOn: "2026-10-02", + targetWeek: "2026-09-21", + scheduleWeekOnly: true, + }); + const weekOnlyPreviousWeek = makeRow({ + scheduledOn: "", + targetWeek: "2026-09-14", + scheduleWeekOnly: true, + }); + const weekOnlyOutside = makeRow({ + scheduledOn: "", + targetWeek: "2026-09-28", + scheduleWeekOnly: true, + }); + const datedOutsideWithWeek = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21" }); + const undated = makeRow({ scheduledOn: "", targetWeek: "" }); + + expect(matchesAdvancedFilters(weekOnlyStaleDate, filters)).toBe(true); + expect(matchesAdvancedFilters(weekOnlyPreviousWeek, filters)).toBe(false); + expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false); + expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false); + expect(matchesAdvancedFilters(undated, filters)).toBe(false); + const noRange = { ...filters, dateRange: null }; + const unscheduledOnly = { ...filters, unscheduledOnly: true }; + const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" }); + const canceledUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Canceled" }); + + expect(matchesAdvancedFilters(undated, noRange)).toBe(true); + expect(matchesAdvancedFilters(datedOutsideWithWeek, noRange)).toBe(true); + expect(matchesAdvancedFilters(weekOnlyOutside, noRange)).toBe(true); + expect(matchesAdvancedFilters(completedUndated, noRange)).toBe(false); + expect(matchesAdvancedFilters(canceledUndated, noRange)).toBe(false); + expect(matchesAdvancedFilters(undated, unscheduledOnly)).toBe(true); + expect(matchesAdvancedFilters(completedUndated, unscheduledOnly)).toBe(false); + expect(matchesAdvancedFilters(canceledUndated, unscheduledOnly)).toBe(false); + }); }); describe("computeIsPastDue", () => { diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts index 2984e96b..aaef5cc8 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -16,6 +16,7 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "dispatcher-1" }, "dispatcher-1", + false, ), ).toBe(true); }); @@ -25,6 +26,7 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "other-dispatcher" }, "dispatcher-1", + false, ), ).toBe(false); }); @@ -34,28 +36,34 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "dispatcher-1" }, "admin-user", + true, ), ).toBe(false); }); - it("allows admin to revoke their own auto-approved uplift as the requester", () => { + it("rejects admin revoking an auto-approved uplift even as its requester", () => { expect( canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "admin-user" }, "admin-user", + true, ), - ).toBe(true); + ).toBe(false); }); it("rejects approved revoke on the Work Order surface (SH-214)", () => { const uplift = { status: "approved" as const, requestedById: "dispatcher-1" }; - expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false); - expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false); + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false); + expect(canRevokeWorkOrderUplift(uplift, "admin-user", true)).toBe(false); }); it("rejects revoke for other statuses", () => { - expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false); - expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false); + expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", false)).toBe( + false, + ); + expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", false)).toBe( + false, + ); }); }); diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts new file mode 100644 index 00000000..638f9609 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + WORK_ORDER_IDS_MAX, + buildWorkOrderIdsHref, + normalizeWorkOrderIds, + parseWorkOrderIdsParam, +} from "@/domain/work-orders/utils/work-order-ids-filter"; + +describe("work-order ids filter", () => { + it("builds the Work Orders link for an exact id set", () => { + expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318"); + }); + + it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => { + expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101"); + expect(buildWorkOrderIdsHref([])).toBe("/workorders"); + }); + + it("reads the ids param, ignoring malformed tokens", () => { + expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]); + expect(parseWorkOrderIdsParam(null)).toEqual([]); + expect(parseWorkOrderIdsParam("")).toEqual([]); + }); + + it("keeps at most the backend limit of distinct ids", () => { + const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1); + const normalized = normalizeWorkOrderIds([...ids, 1, 2]); + + expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); + expect(normalized[0]).toBe(1); + expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts new file mode 100644 index 00000000..dca523e7 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + boardMediaItemsToMediaFiles, + mapBoardDetailMediaItem, +} from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits"; +import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils"; + +describe("photo/video counting for the per-work-order limit", () => { + it("counts a persisted HEIC photo after a refresh", () => { + const heic = mapBoardDetailMediaItem({ + id: 41, + category: 3, + url: "https://cdn.example.com/IMG_0042.HEIC", + fileName: "IMG_0042.HEIC", + }); + const files = boardMediaItemsToMediaFiles([heic]); + + expect(files[0]?.fileType).toBe("image"); + expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 }); + }); + + it.each([ + [{ name: "IMG_0043.HEIC", type: "" }, "image"], + [{ name: "IMG_0044.MOV", type: "" }, "video"], + [{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"], + [{ name: "report.docx", type: "" }, "other"], + ])("classifies a local %o by extension when the browser type is missing", (file, fileType) => { + expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index da86cd31..3f195734 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -1,14 +1,20 @@ import { describe, expect, it } from "vitest"; import { canDeleteWorkOrderMedia, + countWorkOrderMediaKinds, formatMediaSizeLimit, isWorkOrderDocumentsReadOnly, isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, isWorkOrderMediaTypeAllowed, + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + MAX_VIDEOS_PER_WORK_ORDER, MAX_WORK_ORDER_COMPLETION_DOC_BYTES, - MAX_WORK_ORDER_MEDIA_BYTES, + MAX_WORK_ORDER_PHOTO_BYTES, + MAX_WORK_ORDER_VIDEO_BYTES, workOrderExtraDocsAcceptAttribute, + workOrderFileSizeRejection, workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -25,12 +31,52 @@ describe("work-order-media-limits", () => { expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false); }); - it("caps uploads at 200_000_000 bytes and formats decimal MB", () => { - expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000); - expect(formatMediaSizeLimit()).toBe("200 MB"); - expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false); - expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true); - expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); + it("applies the SH-116 per-kind caps with stable generic messages", () => { + const file = (name: string, type: string, size: number) => ({ name, type, size }) as File; + + expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000); + expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000); + expect(MAX_VIDEO_DURATION_SECONDS).toBe(90); + expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10); + expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3); + + expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull(); + expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull(); + expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull(); + expect( + workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)), + ).toBeNull(); + expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe( + "Videos must be 100 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe( + "Photos must be 10 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe( + "Photos must be 10 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe( + "Photos must be 10 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe( + "Documents must be 50 MB or smaller.", + ); + expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe( + false, + ); + expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB"); + }); + + it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => { + expect( + countWorkOrderMediaKinds([ + { fileType: "image" }, + { fileType: "image", isLegacy: true }, + { fileType: "video" }, + { fileType: "document" }, + { fileType: "video", uploadStatus: "failed" }, + ]), + ).toEqual({ photos: 1, videos: 1 }); }); it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => { diff --git a/src/test/lib/probe-video-duration.test.ts b/src/test/lib/probe-video-duration.test.ts new file mode 100644 index 00000000..0e45a171 --- /dev/null +++ b/src/test/lib/probe-video-duration.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function ascii(text: string): Uint8Array { + return Uint8Array.from(text, (char) => char.charCodeAt(0)); +} + +function box(type: string, ...children: Uint8Array[]): Uint8Array { + const body = concat(...children); + const out = new Uint8Array(8 + body.length); + new DataView(out.buffer).setUint32(0, out.length); + out.set(ascii(type), 4); + out.set(body, 8); + return out; +} + +function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array { + const body = new Uint8Array(version1 ? 32 : 20); + const view = new DataView(body.buffer); + body[0] = version1 ? 1 : 0; + if (version1) { + view.setUint32(20, timescale); + view.setBigUint64(24, duration); + } else { + view.setUint32(12, timescale); + view.setUint32(16, Number(duration)); + } + return box("mvhd", body); +} + +/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */ +function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array { + return concat( + box("ftyp", ascii("isom"), new Uint8Array(4)), + box("mdat", new Uint8Array(4096)), + box("moov", movieHeader(timescale, duration, version1)), + ); +} + +function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File { + return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" }); +} + +describe("probeVideoDurationSeconds", () => { + it("reads the duration from a moov box after the media data", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95); + }); + + it("reads a version 1 movie header", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5); + }); + + it("follows 64-bit box sizes", async () => { + const mdat = new Uint8Array(16 + 512); + const view = new DataView(mdat.buffer); + view.setUint32(0, 1); + mdat.set(ascii("mdat"), 4); + view.setBigUint64(8, BigInt(mdat.length)); + const file = concat( + box("ftyp", ascii("qt "), new Uint8Array(4)), + mdat, + box("moov", movieHeader(90_000, 90_000n * 120n)), + ); + + await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120); + }); + + it.each([ + [ + "no moov", + concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))), + ], + ["truncated", mp4(600, 57_000n).slice(0, -10)], + ["zero timescale", mp4(0, 57_000n)], + [ + "oversized box", + concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")), + ], + ["not a video", ascii("%PDF-1.7 not a movie")], + ])("returns null for an unreadable file (%s)", async (_shape, bytes) => { + await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull(); + }); +});