Merge remote-tracking branch 'origin/main' into lane/renovate

# Conflicts:
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
#	package-lock.json
#	package.json
This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:35:01 -03:00
commit ec83829098
34 changed files with 1085 additions and 31 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 195 KiB

438
package-lock.json generated
View file

@ -22,9 +22,9 @@
"@mui/icons-material": "9.2.0",
"@mui/material": "9.2.0",
"@sentry/react": "10.75.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.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
@ -730,6 +730,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",
@ -1162,6 +1174,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.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz",
@ -1431,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",
@ -1501,6 +1547,42 @@
"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",
@ -2392,6 +2474,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",
@ -2405,6 +2640,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.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@ -2755,13 +2999,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": {
@ -2770,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",
@ -2780,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",
@ -2806,19 +3079,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"
}
},
@ -3869,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",
@ -4664,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",
@ -6170,6 +6465,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",
@ -6309,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",
@ -6339,6 +6661,58 @@
"dev": true,
"license": "ISC"
},
"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",
@ -6428,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",
@ -6605,6 +6991,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -62,9 +62,9 @@
"@mui/icons-material": "9.2.0",
"@mui/material": "9.2.0",
"@sentry/react": "10.75.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.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",

View file

@ -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<string, unknown>;
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) {

View file

@ -114,6 +114,9 @@ export function WoTableRowServiceCells({
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
onClear={
row.completedDate && !viewOnly ? () => onPatchRow({ completedDate: "" }) : undefined
}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>

View file

@ -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 (
<Drawer

View file

@ -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 });
}

View file

@ -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) =>

View file

@ -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<typeof useQueryClient>,
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));
},
});
}

View file

@ -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")),

View file

@ -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> = {}): 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(
<table>
<tbody>
<tr>
<WoTableRowServiceCells
row={row}
vendors={[]}
pmTypes={[]}
viewOnly={viewOnly}
handlers={handlers}
onPatchRow={onPatchRow}
/>
</tr>
</tbody>
</table>,
);
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();
});
});

View file

@ -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> = {}): 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(
<table>
<tbody>
<WoTableRow
row={row}
allRows={[row]}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["HVAC"]}
isSelected={false}
isFlashing={false}
dragViz={{ dragId: null, overId: null }}
handlers={handlers}
/>
</tbody>
</table>,
{ 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();
});
});

View file

@ -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(<WorkOrderUpliftsDialog row={row} open onClose={vi.fn()} />, {
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);
});
});

View file

@ -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(<AppSidebar collapsed={false} />, { 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(<AppSidebar collapsed={false} />, {
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",
);
});
});

View file

@ -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(<AppSidebar collapsed={false} />, {
route,
withAuth: false,
});
}
function sidebarLinks(): Record<string, string> {
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");
});
});

View file

@ -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"));
});
});

View file

@ -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,