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 919b91f8..8a314581 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 446bb6af..8f3a6331 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..bcd00f31 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..08602f38 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..a22451fa 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..ad205418 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..ee67ef29 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..0e97260a 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..da22ab30 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..59dc78f3 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..6fab2b85 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-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index fe49083c..cd50b14e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.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..4e0ca158 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..2db0cea4 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..d679e81a 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..7080f709 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..9707bda7 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..a42f3f50 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/package-lock.json b/package-lock.json index 1fe6bc3d..3cf6eb73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,9 @@ "@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", + "@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.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -706,6 +706,18 @@ "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", @@ -1169,6 +1181,31 @@ } } }, + "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", @@ -1340,6 +1377,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 +1432,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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", @@ -2294,6 +2376,159 @@ "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", @@ -2307,6 +2542,15 @@ "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", @@ -2624,13 +2868,29 @@ "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 +2899,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 +2909,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,19 +2948,20 @@ } }, "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" } }, @@ -3779,6 +4053,18 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/copy-anything": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.0.tgz", + "integrity": "sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==", + "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", @@ -4597,6 +4883,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", @@ -6206,6 +6501,12 @@ "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", @@ -6369,6 +6670,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", @@ -6442,6 +6764,58 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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": { + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" + } + }, + "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", + "dependencies": { + "@corvu/utils": "~0.4.2" + }, + "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": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -6548,6 +6922,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", @@ -6729,8 +7115,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", diff --git a/package.json b/package.json index b1fc3700..24b9d023 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,9 @@ "@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", + "@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.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", 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/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 ad995968..21731f67 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/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 84aeb606..e897bb89 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; 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"; function getNavItemAppearance(isActive: boolean, isHover: boolean) { if (isActive) { @@ -129,6 +130,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 ( + query.data === true && !query.isError; + const canReviewUplifts = allows(tier1) || allows(tier2); + + return getNavGroups({ canReviewUplifts }); +} diff --git a/src/config/menu.ts b/src/config/menu.ts index c5b6fce4..2c07d62a 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,10 @@ export const navGroups: NavGroup[] = [ { label: "Sites", icon: MapPin, path: "/locations" }, ], }, + { + title: "DATA MANAGEMENT", + items: [{ label: "Services", icon: Wrench, path: "/settings/services" }], + }, { title: "TEAM", items: [{ label: "Team Members", icon: Users, path: "/team-members" }], @@ -69,8 +75,21 @@ export const navGroups: NavGroup[] = [ // }, ]; +// Approvals navigation is gated on the same server-derived authority the +// uplift approve/reject API enforces (UpliftService.CanApprove tier roles, +// exposed via GET uplifts/can-approve), so the sidebar never shows the queue +// to a user the backend would refuse. +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/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index 3a94e733..368a6767 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 type { CreateWorkOrderUpliftInput, @@ -14,6 +16,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, @@ -48,7 +69,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/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)/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/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..41c11c17 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +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"), { target: { value: "125" } }); + fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } }); + fireEvent.click(screen.getByRole("button", { name: "Create uplift" })); + + 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/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..07e58155 --- /dev/null +++ b/src/test/components/layout/app-sidebar-nav.test.tsx @@ -0,0 +1,115 @@ +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("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/config/menu.test.ts b/src/test/config/menu.test.ts index ac75b38c..61184661 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,39 @@ 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("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/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,