Compare commits
21 commits
fd4c0acd5e
...
201075271e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201075271e | ||
|
|
d573ab59a9 | ||
|
|
6012ebabe7 | ||
|
|
b316168930 | ||
|
|
cfe83fc489 | ||
|
|
8b58eb5f64 | ||
|
|
20ef4a615d | ||
|
|
563f81e163 | ||
|
|
cd888d933d | ||
|
|
ab5e73ce82 | ||
|
|
55f548d507 | ||
|
|
839246380a | ||
|
|
15e2565aca | ||
|
|
aee2ef5164 | ||
|
|
ec56bd0d7d | ||
|
|
460397dacb | ||
|
|
1fd41e693a | ||
|
|
80640642cc | ||
|
|
7b30895aca | ||
|
|
c6e2704175 | ||
|
|
c472ce2a91 |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 195 KiB |
435
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
|
|
@ -18,7 +18,10 @@ import {
|
|||
hasExplicitAvetaFile,
|
||||
resolveExtraDocCategory,
|
||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
countWorkOrderMediaKinds,
|
||||
|
|
@ -189,16 +192,30 @@ export function SlideOverExtrasTab({
|
|||
}: SlideOverExtrasTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
const busy = pending || screening;
|
||||
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||
// Snapshot synchronously: the input is reset right after this handler returns.
|
||||
const selected = Array.from(files);
|
||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length > 0) {
|
||||
onUpload(accepted);
|
||||
|
|
@ -231,7 +248,7 @@ export function SlideOverExtrasTab({
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
|
|
@ -248,9 +265,9 @@ export function SlideOverExtrasTab({
|
|||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span
|
||||
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||
aria-busy={pending || undefined}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
{busy ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
||||
|
|
@ -263,7 +280,7 @@ export function SlideOverExtrasTab({
|
|||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
void addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -9,7 +9,10 @@ import {
|
|||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
|
|
@ -50,18 +53,31 @@ export function MediaUploader({
|
|||
mediaCounts,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||
|
||||
// Snapshot synchronously: the caller resets the input value right after this
|
||||
// handler returns, which can detach a live FileList across awaits.
|
||||
const selected = Array.from(files);
|
||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
|
|
@ -96,7 +112,7 @@ export function MediaUploader({
|
|||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
pending={pending || screening}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={(files) => void addFiles(files)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function DispatchDetailSections({
|
|||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
documents={data.documents ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
|
|
@ -68,6 +69,7 @@ export function DispatchDetailSections({
|
|||
dispatchId={dispatchId}
|
||||
currentNte={data.nteAmount}
|
||||
upliftRequests={data.upliftRequests ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
pollDocument={pollEvidenceDocument}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
|
@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
|
|||
onWithdraw: (request: VendorPortalUpliftRequest) => void;
|
||||
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
|
||||
onCancelRevise: () => void;
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
};
|
||||
|
||||
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
|
||||
|
|
@ -33,6 +35,7 @@ export function UpliftRequestCard({
|
|||
onWithdraw,
|
||||
onRevise,
|
||||
onCancelRevise,
|
||||
mediaUsage,
|
||||
}: UpliftRequestCardProps) {
|
||||
const [revising, setRevising] = useState(false);
|
||||
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
|
||||
|
|
@ -100,6 +103,7 @@ export function UpliftRequestCard({
|
|||
initialRequested={String(request.requestedNTE)}
|
||||
initialReason={request.vendorReason ?? ""}
|
||||
hasExistingEvidence={request.evidenceDocumentId != null}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={uploadingPercent}
|
||||
errorMessage=""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
type VendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
isApprovalRequired,
|
||||
validateUpliftAmount,
|
||||
|
|
@ -13,6 +16,8 @@ type UpliftRequestFormProps = {
|
|||
initialRequested?: string;
|
||||
initialReason?: string;
|
||||
hasExistingEvidence?: boolean;
|
||||
/** Work-order photo/video usage for the evidence pre-check. */
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
submitting: boolean;
|
||||
uploadingPercent: number | undefined;
|
||||
errorMessage: string;
|
||||
|
|
@ -26,6 +31,7 @@ export function UpliftRequestForm({
|
|||
initialRequested = "",
|
||||
initialReason = "",
|
||||
hasExistingEvidence = false,
|
||||
mediaUsage,
|
||||
submitting,
|
||||
uploadingPercent,
|
||||
errorMessage,
|
||||
|
|
@ -57,7 +63,7 @@ export function UpliftRequestForm({
|
|||
|
||||
const chooseFile = async (file?: File) => {
|
||||
const selection = ++latestSelection.current;
|
||||
const validationMessage = file ? await validateVendorDocument(file) : undefined;
|
||||
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
|
||||
// A newer pick may have settled while this one's video metadata was still loading.
|
||||
if (selection !== latestSelection.current) return;
|
||||
setFormError(validationMessage ?? "");
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|||
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
|
||||
import type {
|
||||
VendorPortalDocumentStatus,
|
||||
VendorPortalMediaCounts,
|
||||
VendorPortalUpliftRequest,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
|
||||
import {
|
||||
useVendorUpliftEvidence,
|
||||
|
|
@ -24,6 +26,7 @@ type UpliftRequestsSectionProps = {
|
|||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
};
|
||||
|
||||
type UseVendorUpliftActionsArgs = {
|
||||
|
|
@ -168,7 +171,9 @@ export function UpliftRequestsSection({
|
|||
locked,
|
||||
onChanged,
|
||||
pollDocument,
|
||||
mediaCounts,
|
||||
}: UpliftRequestsSectionProps) {
|
||||
const mediaUsage = vendorMediaUsage(mediaCounts, "evidence");
|
||||
const evidence = useVendorUpliftEvidence({ pollDocument });
|
||||
const {
|
||||
submitting,
|
||||
|
|
@ -205,6 +210,7 @@ export function UpliftRequestsSection({
|
|||
onWithdraw={(r) => void handleWithdraw(r)}
|
||||
onRevise={(r, values) => void handleRevise(r, values)}
|
||||
onCancelRevise={evidence.reset}
|
||||
mediaUsage={mediaUsage}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -212,6 +218,7 @@ export function UpliftRequestsSection({
|
|||
<UpliftRequestForm
|
||||
mode="create"
|
||||
currentNte={currentNte}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={evidence.uploadingPercent}
|
||||
errorMessage={formError}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,82 @@ import { useMutation } from "@tanstack/react-query";
|
|||
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
vendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type {
|
||||
VendorPortalDocument,
|
||||
VendorPortalMediaCounts,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type VendorPortalDocumentsProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
documents: VendorPortalDocument[];
|
||||
locked: boolean;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
onChanged: () => Promise<void>;
|
||||
};
|
||||
|
||||
type VendorPortalDocumentRowProps = {
|
||||
document: VendorPortalDocument;
|
||||
locked: boolean;
|
||||
uploading: boolean;
|
||||
onDownload: () => void;
|
||||
onReplace: () => void;
|
||||
};
|
||||
|
||||
function VendorPortalDocumentRow({
|
||||
document,
|
||||
locked,
|
||||
uploading,
|
||||
onDownload,
|
||||
onReplace,
|
||||
}: VendorPortalDocumentRowProps) {
|
||||
return (
|
||||
<article className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={onDownload}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploading}
|
||||
onClick={onReplace}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type UploadRequest = {
|
||||
file: File;
|
||||
replacementId?: number;
|
||||
|
|
@ -24,12 +89,15 @@ export function VendorPortalDocuments({
|
|||
dispatchId,
|
||||
documents,
|
||||
locked,
|
||||
mediaCounts,
|
||||
onChanged,
|
||||
}: VendorPortalDocumentsProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [replacementId, setReplacementId] = useState<number>();
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
|
||||
const selectionRef = useRef(0);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||
|
|
@ -51,6 +119,7 @@ export function VendorPortalDocuments({
|
|||
});
|
||||
|
||||
const chooseFile = (documentId?: number) => {
|
||||
selectionRef.current += 1;
|
||||
setValidationError(undefined);
|
||||
uploadMutation.reset();
|
||||
setReplacementId(documentId);
|
||||
|
|
@ -62,7 +131,15 @@ export function VendorPortalDocuments({
|
|||
return;
|
||||
}
|
||||
|
||||
const validationMessage = await validateVendorDocument(file);
|
||||
const selection = ++selectionRef.current;
|
||||
const replaced = documents.find((document) => document.id === replacementId);
|
||||
const validationMessage = await validateVendorDocument(
|
||||
file,
|
||||
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
|
||||
);
|
||||
if (selection !== selectionRef.current) {
|
||||
return;
|
||||
}
|
||||
if (validationMessage) {
|
||||
setValidationError(validationMessage);
|
||||
return;
|
||||
|
|
@ -93,45 +170,14 @@ export function VendorPortalDocuments({
|
|||
)}
|
||||
|
||||
{documents.map((document) => (
|
||||
<article key={document.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => chooseFile(document.id)}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
<VendorPortalDocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
locked={locked}
|
||||
uploading={uploadMutation.isPending}
|
||||
onDownload={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
onReplace={() => chooseFile(document.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{progress !== undefined && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
src/components/layout/use-nav-groups.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
/**
|
||||
* SH-116 client-confirmed media contract on the vendor portal: photos up to
|
||||
* Client-confirmed media contract on the vendor portal: photos up to
|
||||
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
|
||||
* PDF documents keep the previous 10 MB document cap.
|
||||
*/
|
||||
|
|
@ -42,12 +43,65 @@ function resolveKind(file: File): VendorUploadKind | null {
|
|||
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
|
||||
}
|
||||
|
||||
export async function validateVendorDocument(file: File): Promise<string | undefined> {
|
||||
/** Photos/videos already counted against the work order for one upload, with the limits. */
|
||||
export type VendorMediaUsage = {
|
||||
photos: number;
|
||||
videos: number;
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The count basis the server uses for this upload. A completion upload replaces a
|
||||
* document: the latest one by default (already excluded in `completion*`), or the
|
||||
* one the vendor picked, which is not counted when it is the same kind. Evidence
|
||||
* uploads count everything. Undefined when the backend does not report counts.
|
||||
*/
|
||||
export function vendorMediaUsage(
|
||||
counts: VendorPortalMediaCounts | null | undefined,
|
||||
purpose: "completion" | "evidence",
|
||||
replacedContentType?: string,
|
||||
): VendorMediaUsage | undefined {
|
||||
if (!counts) return undefined;
|
||||
const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
|
||||
if (purpose === "evidence") {
|
||||
return { ...limits, photos: counts.photos, videos: counts.videos };
|
||||
}
|
||||
if (replacedContentType === undefined) {
|
||||
return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
|
||||
}
|
||||
const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
|
||||
return {
|
||||
...limits,
|
||||
photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
|
||||
videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
|
||||
if (kind === "photo" && usage.photos >= usage.maxPhotos) {
|
||||
return `A work order can have at most ${usage.maxPhotos} photos.`;
|
||||
}
|
||||
if (kind === "video" && usage.videos >= usage.maxVideos) {
|
||||
return `A work order can have at most ${usage.maxVideos} videos.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function validateVendorDocument(
|
||||
file: File,
|
||||
usage?: VendorMediaUsage,
|
||||
): Promise<string | undefined> {
|
||||
const kind = resolveKind(file);
|
||||
if (!kind) {
|
||||
return UNSUPPORTED_TYPE_MESSAGE;
|
||||
}
|
||||
|
||||
const countMessage = usage && countRejection(kind, usage);
|
||||
if (countMessage) {
|
||||
return countMessage;
|
||||
}
|
||||
|
||||
if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
|
||||
return "Photos must be 10 MB or smaller.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
|
|||
refusedAt?: string;
|
||||
}
|
||||
|
||||
/** Counts for a new completion version exclude the document it replaces. */
|
||||
export interface VendorPortalMediaCounts {
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
photos: number;
|
||||
videos: number;
|
||||
completionPhotos: number;
|
||||
completionVideos: number;
|
||||
}
|
||||
|
||||
export interface VendorPortalDocument {
|
||||
id: number;
|
||||
originalFileName: string;
|
||||
|
|
@ -137,4 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
|||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
signoffs: VendorPortalSignoff[];
|
||||
documents: VendorPortalDocument[];
|
||||
/** Per-work-order photo/video usage; absent until the backend reports it. */
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function cryptoRandomId(): string {
|
|||
|
||||
function classifyFileName(fileName: string, url: string): FileCategory {
|
||||
const name = `${fileName} ${url}`.toLowerCase();
|
||||
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type WorkOrderUploadScreen = {
|
|||
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
|
||||
|
||||
/**
|
||||
* SH-116 browser pre-check shared by every dispatcher upload surface: type, size,
|
||||
* Browser pre-check shared by every dispatcher upload surface: type, size,
|
||||
* per-work-order photo/video count (including earlier files in the same selection)
|
||||
* and video duration. The server enforces the same contract; a video whose duration
|
||||
* the browser cannot read is never blocked here.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/**
|
||||
* SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB
|
||||
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
|
||||
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
|
||||
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
|
||||
* the Completion Doc → Media tab and the vendor portal upload. Duration is
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
MediaCategory,
|
||||
MediaFile,
|
||||
} from "@/domain/work-orders/types/work-order-media";
|
||||
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
let mediaIdCounter = 0;
|
||||
|
||||
|
|
@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
|
|||
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
|
||||
return { fileType: "pdf", category: "Report" };
|
||||
}
|
||||
// Phones often send an empty or octet-stream type; fall back to the extension.
|
||||
const kind = resolveWorkOrderUploadKind(f);
|
||||
if (kind === "photo") return { fileType: "image", category: "Photo" };
|
||||
if (kind === "video") return { fileType: "video", category: "Video" };
|
||||
return { fileType: "other", category: "Other" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +1,75 @@
|
|||
/**
|
||||
* Best-effort client-side video duration probe. Resolves null when the browser
|
||||
* cannot decode the container's metadata — callers must not block the upload in
|
||||
* that case (SH-116: the server enforces the limit from the MP4/MOV header and
|
||||
* also lets unreadable metadata through).
|
||||
* Best-effort video duration probe for MP4/MOV. Reads the movie header
|
||||
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
|
||||
* phone file with `moov` after a large `mdat` costs only a few small reads.
|
||||
* Resolves null when the header cannot be read — callers must not block the
|
||||
* upload in that case (the server enforces the limit from the same
|
||||
* header and also lets unreadable metadata through).
|
||||
*/
|
||||
export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000;
|
||||
const MAX_BOXES_PER_LEVEL = 1024;
|
||||
|
||||
export function probeVideoDurationSeconds(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement("video");
|
||||
let settled = false;
|
||||
type Box = { bodyStart: number; end: number };
|
||||
|
||||
const done = (value: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS);
|
||||
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
window.clearTimeout(timeout);
|
||||
done(Number.isFinite(video.duration) ? video.duration : null);
|
||||
};
|
||||
video.onerror = () => {
|
||||
window.clearTimeout(timeout);
|
||||
done(null);
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
async function readView(file: Blob, start: number, length: number): Promise<DataView | null> {
|
||||
if (start < 0 || length <= 0 || start + length > file.size) return null;
|
||||
const buffer = await file.slice(start, start + length).arrayBuffer();
|
||||
return buffer.byteLength === length ? new DataView(buffer) : null;
|
||||
}
|
||||
|
||||
function boxType(view: DataView): string {
|
||||
return String.fromCharCode(
|
||||
view.getUint8(4),
|
||||
view.getUint8(5),
|
||||
view.getUint8(6),
|
||||
view.getUint8(7),
|
||||
);
|
||||
}
|
||||
|
||||
async function findBox(file: Blob, start: number, end: number, type: string): Promise<Box | null> {
|
||||
let offset = start;
|
||||
for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) {
|
||||
const header = await readView(file, offset, Math.min(16, end - offset));
|
||||
if (!header) return null;
|
||||
let size = header.getUint32(0);
|
||||
let headerLength = 8;
|
||||
if (size === 1) {
|
||||
if (header.byteLength < 16) return null;
|
||||
const largeSize = header.getBigUint64(8);
|
||||
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||
size = Number(largeSize);
|
||||
headerLength = 16;
|
||||
} else if (size === 0) {
|
||||
size = end - offset;
|
||||
}
|
||||
if (size < headerLength || offset + size > end) return null;
|
||||
if (boxType(header) === type) {
|
||||
return { bodyStart: offset + headerLength, end: offset + size };
|
||||
}
|
||||
offset += size;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise<number | null> {
|
||||
const version = await readView(file, mvhd.bodyStart, 1);
|
||||
if (!version) return null;
|
||||
const isVersion1 = version.getUint8(0) === 1;
|
||||
const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20);
|
||||
if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null;
|
||||
const timescale = fields.getUint32(isVersion1 ? 20 : 12);
|
||||
const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16));
|
||||
const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn;
|
||||
if (timescale === 0 || duration === unknown) return null;
|
||||
return Number(duration) / timescale;
|
||||
}
|
||||
|
||||
export async function probeVideoDurationSeconds(file: Blob): Promise<number | null> {
|
||||
try {
|
||||
const moov = await findBox(file, 0, file.size, "moov");
|
||||
if (!moov) return null;
|
||||
const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd");
|
||||
return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -79,4 +79,36 @@ describe("SlideOverExtrasTab SH-116 media contract", () => {
|
|||
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a second pick while the first one is still being checked", async () => {
|
||||
let finishProbe: ((seconds: number) => void) | undefined;
|
||||
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve;
|
||||
}),
|
||||
);
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[]}
|
||||
mediaCounts={{ photos: 0, videos: 2 }}
|
||||
isReadOnly={false}
|
||||
onUpload={onUpload}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
|
||||
const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
|
||||
|
||||
selectFiles([first]);
|
||||
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||
expect(document.querySelector('input[type="file"]')).toBeDisabled();
|
||||
selectFiles([second]);
|
||||
finishProbe?.(30);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
|
||||
expect(onUpload).toHaveBeenCalledWith([first]);
|
||||
expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,16 @@ describe("UpliftRequestForm revise mode", () => {
|
|||
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses photo evidence when the work order already has 10 photos", async () => {
|
||||
renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/evidence/i), {
|
||||
target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the current NTE threshold in the actionable error", () => {
|
||||
renderForm({ initialRequested: "900", currentNte: 1000 });
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,73 @@ describe("VendorPortalDocuments", () => {
|
|||
expect(await screen.findByRole("alert")).toHaveTextContent(message);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Three videos already on the work order (dispatcher and vendor uploads together).
|
||||
const fullCounts = {
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
photos: 10,
|
||||
videos: 3,
|
||||
completionPhotos: 10,
|
||||
completionVideos: 3,
|
||||
};
|
||||
const videoDocument: VendorPortalDocument = {
|
||||
...quarantinedDocument,
|
||||
id: 21,
|
||||
originalFileName: "walkthrough.mp4",
|
||||
contentType: "video/mp4",
|
||||
};
|
||||
|
||||
it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
mediaCounts={fullCounts}
|
||||
locked={false}
|
||||
onChanged={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"A work order can have at most 3 videos.",
|
||||
);
|
||||
choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"A work order can have at most 10 photos.",
|
||||
),
|
||||
);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a vendor replace one of the counted videos on a full work order", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[videoDocument]}
|
||||
mediaCounts={fullCounts}
|
||||
locked={false}
|
||||
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
|
||||
choose(replacement);
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||
expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
|
||||
});
|
||||
});
|
||||
|
||||
it("reports upload progress and refreshes the dispatch after success", async () => {
|
||||
|
|
@ -180,4 +247,40 @@ describe("VendorPortalDocuments", () => {
|
|||
finishUpload?.();
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
|
||||
let finishProbe: ((seconds: number) => void) | undefined;
|
||||
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve;
|
||||
}),
|
||||
);
|
||||
const upload = vi
|
||||
.spyOn(vendorPortalApi, "uploadDocument")
|
||||
.mockResolvedValue(quarantinedDocument);
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
locked={false}
|
||||
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
const input = screen.getByLabelText("Choose completion document");
|
||||
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
|
||||
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
|
||||
|
||||
fireEvent.change(input, { target: { files: [video] } });
|
||||
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||
fireEvent.change(input, { target: { files: [photo] } });
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||
finishProbe?.(30);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
expect(upload.mock.calls[0][0].file).toBe(photo);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
58
src/test/components/layout/app-sidebar-can-approve.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
115
src/test/components/layout/app-sidebar-nav.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardMediaItemsToMediaFiles,
|
||||
mapBoardDetailMediaItem,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
describe("photo/video counting for the per-work-order limit", () => {
|
||||
it("counts a persisted HEIC photo after a refresh", () => {
|
||||
const heic = mapBoardDetailMediaItem({
|
||||
id: 41,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/IMG_0042.HEIC",
|
||||
fileName: "IMG_0042.HEIC",
|
||||
});
|
||||
const files = boardMediaItemsToMediaFiles([heic]);
|
||||
|
||||
expect(files[0]?.fileType).toBe("image");
|
||||
expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ name: "IMG_0043.HEIC", type: "" }, "image"],
|
||||
[{ name: "IMG_0044.MOV", type: "" }, "video"],
|
||||
[{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
|
||||
[{ name: "report.docx", type: "" }, "other"],
|
||||
])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
|
||||
expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
|
||||
});
|
||||
});
|
||||
93
src/test/lib/probe-video-duration.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ascii(text: string): Uint8Array {
|
||||
return Uint8Array.from(text, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function box(type: string, ...children: Uint8Array[]): Uint8Array {
|
||||
const body = concat(...children);
|
||||
const out = new Uint8Array(8 + body.length);
|
||||
new DataView(out.buffer).setUint32(0, out.length);
|
||||
out.set(ascii(type), 4);
|
||||
out.set(body, 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array {
|
||||
const body = new Uint8Array(version1 ? 32 : 20);
|
||||
const view = new DataView(body.buffer);
|
||||
body[0] = version1 ? 1 : 0;
|
||||
if (version1) {
|
||||
view.setUint32(20, timescale);
|
||||
view.setBigUint64(24, duration);
|
||||
} else {
|
||||
view.setUint32(12, timescale);
|
||||
view.setUint32(16, Number(duration));
|
||||
}
|
||||
return box("mvhd", body);
|
||||
}
|
||||
|
||||
/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */
|
||||
function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array {
|
||||
return concat(
|
||||
box("ftyp", ascii("isom"), new Uint8Array(4)),
|
||||
box("mdat", new Uint8Array(4096)),
|
||||
box("moov", movieHeader(timescale, duration, version1)),
|
||||
);
|
||||
}
|
||||
|
||||
function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" });
|
||||
}
|
||||
|
||||
describe("probeVideoDurationSeconds", () => {
|
||||
it("reads the duration from a moov box after the media data", async () => {
|
||||
await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95);
|
||||
});
|
||||
|
||||
it("reads a version 1 movie header", async () => {
|
||||
await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5);
|
||||
});
|
||||
|
||||
it("follows 64-bit box sizes", async () => {
|
||||
const mdat = new Uint8Array(16 + 512);
|
||||
const view = new DataView(mdat.buffer);
|
||||
view.setUint32(0, 1);
|
||||
mdat.set(ascii("mdat"), 4);
|
||||
view.setBigUint64(8, BigInt(mdat.length));
|
||||
const file = concat(
|
||||
box("ftyp", ascii("qt "), new Uint8Array(4)),
|
||||
mdat,
|
||||
box("moov", movieHeader(90_000, 90_000n * 120n)),
|
||||
);
|
||||
|
||||
await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"no moov",
|
||||
concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))),
|
||||
],
|
||||
["truncated", mp4(600, 57_000n).slice(0, -10)],
|
||||
["zero timescale", mp4(0, 57_000n)],
|
||||
[
|
||||
"oversized box",
|
||||
concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")),
|
||||
],
|
||||
["not a video", ascii("%PDF-1.7 not a movie")],
|
||||
])("returns null for an unreadable file (%s)", async (_shape, bytes) => {
|
||||
await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||