Compare commits

...

48 commits

Author SHA1 Message Date
renovate[bot]
941e2e1a42
fix(deps): update npm minor and patch 2026-09-25 16:05:15 +00:00
Alexandre Brandizzi
6e0967eaf0
Merge pull request #267 from Sea-Haven-Industries/feat/ab/wo-ids-filter
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(notifications): SLA at-risk banner opening Work Orders on an exact id set
2026-09-25 15:58:37 +00:00
Alexandre Brandizzi
7de34d359e Merge branch 'feat/ab/wo-ids-filter' into feat/ab/sh-295-sla-alerts 2026-09-25 11:52:57 -03:00
Alexandre Brandizzi
74dd1292a4 fix(workorders): applying a panel filter replaces the exact id selection
The server honours only the id set when one is sent, so a facet applied during an ids drill-down showed as an active chip while doing nothing. Applying any facet now drops the selection; applying the panel unchanged keeps it. The ids param is also capped at the server limit whatever built the set.
2026-09-25 11:52:27 -03:00
Alexandre Brandizzi
31e9db2d02 feat(notifications): SLA at-risk banner, 50% toast and breach acknowledgement
Dispatchers see a one-line banner and a one-time toast while a Reactive/Emergency work order is past half of its SEV response window; the banner opens the board on exactly those work orders and dismisses for the session. Missed deadlines stay in the notification center until acknowledged, and Clear all skips them.
2026-09-25 11:36:25 -03:00
Alexandre Brandizzi
5f8218b09a test(workorders): avoid Array.prototype.at outside the ES2020 lib 2026-09-25 11:11:09 -03:00
Alexandre Brandizzi
e052dcb37a feat(workorders): filter board to an exact work-order id set
/workorders?ids=101,205,318 opens the board on exactly those work orders
through board/search's ids filter. The id set replaces every other
drill-down filter, a chip reads "Showing N selected work orders" and
clearing it returns to the weekly board. buildWorkOrderIdsHref builds
the link for alerts and linked-work-order lists.
2026-09-25 11:06:09 -03:00
Alexandre Brandizzi
358ec3e890
Merge pull request #261 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-392: Unassigned KPI tile on the Dashboard
2026-09-25 06:52:00 +00:00
Alexandre Brandizzi
2084cfa453
Merge pull request #266 from Sea-Haven-Industries/fix/ab/sh-400-vendor-readonly-uplifts
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Show work-order uplift requests read-only in the Vendor Portal
2026-09-25 06:20:35 +00:00
Alexandre Brandizzi
43dd95e7fd Show the requested NTE total on work-order uplift cards
A request raised from the work order stores the increase, so the vendor card
read a +90 request on a 600 NTE as "Requested $90.00 (was $600.00)". The card
now shows the new total, $690.00, for those requests; vendor requests are
unchanged.
2026-09-25 03:12:17 -03:00
Alexandre Brandizzi
9f46a91cb9
Merge pull request #260 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
fix(media): apply the SH-116 media contract to every upload surface
2026-09-25 06:06:59 +00:00
Alexandre Brandizzi
33f7172598 Move uplift card action gating into a helper
Keeps UpliftRequestCard within the changed-file complexity limit.
2026-09-25 03:03:30 -03:00
Alexandre Brandizzi
b91682f53c Merge remote-tracking branch 'origin/main' into lane/pr261 2026-09-25 03:02:25 -03:00
Alexandre Brandizzi
70f8b303b5 Show work-order uplift requests read-only in the Vendor Portal
Revise and Withdraw now appear only on uplift requests the vendor raised.
Requests raised from the work order stay visible without either control.
2026-09-25 02:55:04 -03:00
Alexandre Brandizzi
52cbb87126
Merge pull request #259 from Sea-Haven-Industries/fix/ab/sh-391-adv-filter-date-range
fix(workorders): make the Advanced Filters date range narrow results (SH-391)
2026-09-25 05:53:39 +00:00
Alexandre Brandizzi
7c665464f8
Merge pull request #263 from Sea-Haven-Industries/fix/ab/sh-398-uplift-amount
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(uplifts): show one uplift amount on every approvals surface
2026-09-25 05:02:57 +00:00
Alexandre Brandizzi
56ca5b0c39 test(uplifts): assert the uplift amount on the approver e2e buttons 2026-09-25 01:58:47 -03:00
Alexandre Brandizzi
3d10ba8a0c style(uplifts): apply prettier to uplift approval tests 2026-09-25 01:53:05 -03:00
Alexandre Brandizzi
d653ca0095 fix(vendor-portal): hold uplift submit until the evidence check settles
Picking a video as uplift evidence reads its movie header before the file is stored in form state. A submit that landed inside that window validated the previous (usually empty) evidence, showed the missing-evidence error and dropped the request, forcing a second click. The submit button is now disabled and reads "Checking evidence…" while the latest pick is being checked, and the submit handler ignores submissions in that window.
2026-09-25 01:50:27 -03:00
Alexandre Brandizzi
28e30f8edb fix(uplifts): show the requested increase as the one uplift amount
The queue Amount column, the detail modal and the reject/revoke dialogs
showed requestedNTE (the resulting NTE total) while the decision toasts
showed delta (the increase), so a work order with an existing NTE named
two different figures for one request. Route every surface through one
upliftAmount helper that returns the increase, matching the prototype's
single uplift amount, and show it on both Approve buttons.
2026-09-25 01:49:24 -03:00
Alexandre Brandizzi
39ee98e09b fix(vendor-portal): advertise phone photo and video types for uplift evidence
The evidence picker filtered to PDF/JPG/PNG and its label and missing-file
messages said the same, while the pre-check (and the server, which applies
the SH-116 allowlist and work-order counts to UpliftEvidence uploads)
accepts HEIC, MP4 and MOV. On a phone the picker hid exactly the photos and
clips the validator would pass.

The evidence input now uses the same accept filter as the completion
upload, exported once from document-validation, and the label and both
missing-evidence messages name all six types.
2026-09-25 01:43:34 -03:00
Alexandre Brandizzi
ef6cde64c8 fix(vendor-portal): state the 10 MB PDF cap in the upload hint
The vendor portal hint listed the photo and video limits but not the PDF one, so a vendor rejected for an 11 MB PDF saw a limit that was never stated. PDFs are the only accepted kind whose cap was missing from the copy; a test now pins the full hint.
2026-09-24 23:49:19 -03:00
Alexandre Brandizzi
aec11782b3
Merge pull request #262 from Sea-Haven-Industries/fix/ab/sh-394-exposure-dash
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(uplifts): show a dash for Approved on WO when nothing is granted (SH-394)
2026-09-25 02:31:59 +00:00
Alexandre Brandizzi
eb01c59471 test(uplifts): drop the ticket key from the Approved on WO test name 2026-09-24 23:03:42 -03:00
Alexandre Brandizzi
d573ab59a9 refactor(vendor-portal): extract document row from VendorPortalDocuments
The changed-file maintainability gate caps functions at 150 lines;
VendorPortalDocuments reached 156. Move the per-document row into
VendorPortalDocumentRow with no behavior change.
2026-09-24 23:03:23 -03:00
Alexandre Brandizzi
b8d24d75a3 test(uplifts): cover an unknown row total with nothing granted on the WO (SH-394) 2026-09-24 23:00:27 -03:00
Alexandre Brandizzi
ec3f97dcf6 fix(uplifts): show a dash for Approved on WO when nothing is granted (SH-394)
A work order with a zero granted total rendered $0.00 in the Pending
table's Approved on WO column and in the detail modal's Total line.
Both now share isGrantedAmount, so zero or unknown totals render a dash
and the column always matches the modal Total.
2026-09-24 22:54:39 -03:00
Alexandre Brandizzi
6012ebabe7 fix(media): count HEIC and untyped phone files, guard overlapping picks
Persisted HEIC files and local files with an empty or octet-stream type
were classified as "other", so they did not count toward the 10 photo /
3 video limit. Both classifiers now recognise them by extension.

The Photos & Videos uploader and Extra Docs ignore a new selection while
the previous one is still being screened, and the vendor portal drops an
earlier pick whose video check finishes after a newer pick.

Also removes ticket keys from source comments.
2026-09-24 22:51:17 -03:00
Alexandre Brandizzi
8bb4c0c512 fix(dashboard): open the Unassigned tile as the Unassigned filter option
The tile link sent the board API token __unassigned__ as the dispatcher.
The drill-down parser stores that as an Advanced Filters dispatcher id,
while the filter UI only knows __unassigned, so the banner showed a raw
__unassigned__ chip, Assigned to did not tick Unassigned, and the option
could not clear it. Send ASSIGNEE_FILTER_UNASSIGNED, as the notification
queue link already does; toBoardSearchQueryParams still maps it to
__unassigned__ for /board/search, so the rows are unchanged.
2026-09-24 22:45:10 -03:00
Alexandre Brandizzi
62e38e4b81 chore(workorders): keep ticket keys out of test names
The review contract bars Jira keys in source comments and test names;
ticket identity lives in the PR, commit and branch. The previous commit
cleaned the source files; this strips the six (SH-391) suffixes the
branch added to test names and a test comment, keeping the behavioural
wording.
2026-09-24 22:41:05 -03:00
Alexandre Brandizzi
b316168930 fix(vendor-portal): pre-check the per-work-order photo/video limit
The vendor completion upload and uplift evidence checked type, size and
duration but not the 10-photo / 3-video work-order limit. Use the counts the
dispatch detail now reports (shoc-backend#173), on the same basis as the
server: a new completion version does not count the document it replaces.
When the backend does not report counts yet, the server check still applies.
2026-09-24 22:29:20 -03:00
Alexandre Brandizzi
67cb490d52 test(dashboard): refresh visual baselines for the Unassigned tile and the Services entry
Rendered by the CI Playwright container at f64447f5.
2026-09-24 22:20:14 -03:00
Alexandre Brandizzi
cfe83fc489 fix(media): read video duration from the MP4/MOV header in the browser
The browser pre-check decoded the picked file through a video element and
an object URL, which CodeQL flags as DOM text reinterpreted as HTML. Parse
the moov/mvhd movie header from file slices instead, the same way the
server enforces the 90-second limit, so both sides read one duration.
Unreadable headers still never block an upload.
2026-09-24 22:14:42 -03:00
Alexandre Brandizzi
f64447f52c chore(dashboard): keep ticket keys out of source comments 2026-09-24 22:06:24 -03:00
Alexandre Brandizzi
7984640a5f Merge remote-tracking branch 'origin/fix/ab/sh-391-adv-filter-date-range' into feat/ab/sh-392-dashboard-unassigned 2026-09-24 22:05:33 -03:00
Alexandre Brandizzi
792dc6917b Merge remote-tracking branch 'origin/main' into feat/ab/sh-392-dashboard-unassigned
# Conflicts:
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
2026-09-24 22:05:10 -03:00
Alexandre Brandizzi
c1fd8f6d88 chore(workorders): keep ticket keys out of source comments 2026-09-24 22:03:23 -03:00
Alexandre Brandizzi
c99e81d0d7 fix(media): pre-check duration and per-work-order counts on Extra Docs
Extra Docs advertised the 90-second limit but only checked type and size.
Both dispatcher surfaces now share one screening step (type, size, count,
duration), and the Completion Doc media tab and Extra Docs count the whole
work order's photos and videos rather than only their own tab's share.
Failed local uploads no longer count toward the limit.
2026-09-24 22:01:22 -03:00
Alexandre Brandizzi
60b0638ea3 test(dashboard): update visual baselines for the Unassigned tile (SH-392)
Rendered by the CI Playwright container (mcr.microsoft.com/playwright:v1.61.1-noble)
at fca87b67.
2026-09-24 21:52:21 -03:00
Alexandre Brandizzi
fca87b67cb test(dashboard): give the visual fixture an Unassigned count (SH-392) 2026-09-24 21:48:16 -03:00
Alexandre Brandizzi
11a753a2e9 docs(media): note the server now enforces the 90-second video limit 2026-09-24 21:39:07 -03:00
Alexandre Brandizzi
ed931ea2eb SH-392: Unassigned KPI tile on the Dashboard
Shows open work orders with no dispatcher for the selected period and
drills into the same list on the board (dispatcher Unassigned, open
statuses, same dates; All time includes undated work orders).
2026-09-24 21:34:35 -03:00
Alexandre Brandizzi
51ca244524 fix(workorders): keep closed undated WOs out of the board page like the search (SH-391)
With no range selected, or with Unscheduled, the board search adds only
open undated work orders; completed and canceled ones without a date are
never unscheduled. The board page kept them. It now applies the same rule,
so both return the same rows.
2026-09-24 21:34:24 -03:00
Alexandre Brandizzi
b5da537c93 fix(media): classify client uploads by the server's type rule
A foreign declared type (e.g. video/3gpp on a .jpg) gave the file the video
size allowance in the browser while the server sizes it as a photo. Use the
same rule as the server: an allowlisted type decides, otherwise the extension.
2026-09-24 21:31:03 -03:00
Alexandre Brandizzi
f69c8a04eb fix(workorders): match a range on the board the same way the search does (SH-391)
A week-only row that still carries a scheduled date outside the range
passed the board search (its target week overlaps) but failed the
on-page filter, which judged it by the date alone. Both now accept a row
dated in the range or a week-only row whose target week overlaps it.
2026-09-24 21:10:40 -03:00
Alexandre Brandizzi
f65a48077d fix(vendor-portal): validate uploads against the server allowlist
Any image/* or video/* type passed the client check, so GIF, WebP and WebM
were only rejected after upload. Resolve the kind from the same allowlist
the server uses: an allowlisted browser type, otherwise the extension.
2026-09-24 21:01:36 -03:00
Alexandre Brandizzi
44e96cf2a3 fix(media): apply the SH-116 media contract to every upload surface
Photos up to 10 MB (JPG/PNG/HEIC), videos up to 100 MB and 90 s (MP4/MOV),
at most 10 photos and 3 videos per work order, pre-validated with stable
generic messages on the Photos & Videos modal, the Completion Doc media tab,
Extra Docs and the vendor portal. Video duration is read from metadata when
the browser can; unreadable metadata never blocks. Mobile MIME variants
(empty type, octet-stream with a video extension, QuickTime) stay accepted.
The signed completion PDF keeps its 50 MB cap.
2026-09-24 20:59:26 -03:00
Alexandre Brandizzi
0d6af0da59 fix(workorders): make the Advanced Filters date range narrow results (SH-391)
The Advanced Filters default was This week, and that value doubled as no
range, so an explicit This week searched every week. Other ranges passed
every undated WO through on the server. No range is now its own state
(nothing selected, like the prototype; clicking a selected range clears it).
It searches every week with undated rows, via includeDateless. Any selected
range narrows strictly. The pinned Unassigned queue and the WO# duplicate
lookup keep spanning every week.
2026-09-24 20:58:47 -03:00
91 changed files with 3177 additions and 343 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -191,7 +191,11 @@ async function mockDashboardApi(page: Page, userRoles: string) {
return;
}
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
if (pathname.includes("/Dashboard/Stats")) {
// The server reports no unassigned work for a dispatcher-scoped view.
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
return fulfillJson(route, { data: { ...STATS, unassigned } });
}
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
if (pathname.includes("/Dashboard/Performance"))
return fulfillJson(route, { data: PERFORMANCE });

View file

@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
@ -349,7 +349,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve" }).click();
await approvalRow.getByRole("button", { name: "Approve $25.00" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Reject" }).click();
@ -360,7 +360,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvedRow.getByRole("button", { name: "Revoke" }).click();
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
await page.getByRole("button", { name: "Revoke $125" }).click();
await page.getByRole("button", { name: "Revoke $25.00" }).click();
await expect
.poll(() => decisions)

166
package-lock.json generated
View file

@ -19,9 +19,9 @@
"@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "9.2.0",
"@mui/material": "9.2.0",
"@sentry/react": "10.75.0",
"@mui/icons-material": "9.4.0",
"@mui/material": "9.4.0",
"@sentry/react": "10.75.2",
"@tanstack/query-broadcast-client-experimental": "5.103.1",
"@tanstack/react-query": "5.103.1",
"@tanstack/react-query-devtools": "5.103.1",
@ -64,7 +64,7 @@
"prettier": "^3.9.8",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"vitest": "^4.1.11"
},
@ -1584,9 +1584,9 @@
}
},
"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",
"integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
"integrity": "sha512-d+gIYM1raJP87yrVQcVg+Q3vycVMArMkgu/TjJLi2vTvO9FHKWNRR1xs9nnN4eLpM9nGxxm02UMvc9BrmcUKvQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
@ -1594,12 +1594,12 @@
}
},
"node_modules/@mui/icons-material": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz",
"integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.4.0.tgz",
"integrity": "sha512-5PVgBYtLOXTk6u0YUAjoV9GoTUQDbeZ8g+pus2h0GphLT/rpmftRnB5D/Kw6QCAovB7EyBX7UxKdZqPBBAHUKw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
"@babel/runtime": "^7.29.7"
},
"engines": {
"node": ">=14.0.0"
@ -1609,7 +1609,7 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^9.2.0",
"@mui/material": "^9.4.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
@ -1620,22 +1620,22 @@
}
},
"node_modules/@mui/material": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz",
"integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.4.0.tgz",
"integrity": "sha512-6KKUIvkQ2r/s48s9VZV9JEZ1W2dRa7RndVsF+Ipx4CYxDOQeozeRsS5r9NiheFf8A5hpbqJJIGoX7hjg5X4XUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^9.2.0",
"@mui/system": "^9.2.0",
"@mui/types": "^9.1.1",
"@mui/utils": "^9.2.0",
"@babel/runtime": "^7.29.7",
"@mui/core-downloads-tracker": "^9.4.0",
"@mui/system": "^9.4.0",
"@mui/types": "^9.4.0",
"@mui/utils": "^9.4.0",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1",
"react-is": "^19.2.6",
"react-is": "^19.2.8",
"react-transition-group": "^4.4.5"
},
"engines": {
@ -1648,7 +1648,7 @@
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^9.2.0",
"@mui/material-pigment-css": "^9.4.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@ -1669,13 +1669,13 @@
}
},
"node_modules/@mui/private-theming": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz",
"integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.4.0.tgz",
"integrity": "sha512-qQpX/2XPGDz5F2OIWzo4kkorURlDeXGKytRQudXCdLS5TuwKsMKaZyx77LZEVSB73c7o3UrLWwQ/goRuHBdtjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/utils": "^9.2.0",
"@babel/runtime": "^7.29.7",
"@mui/utils": "^9.4.0",
"prop-types": "^15.8.1"
},
"engines": {
@ -1696,12 +1696,12 @@
}
},
"node_modules/@mui/styled-engine": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz",
"integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.4.0.tgz",
"integrity": "sha512-SlqVEYqnyEgXMFW4JuIDMelyhKzoWBJ85v2mNCQd0XtU0uk7U9E1zaJZ06XqodhA8oJrb+4Q+3E/JlwBe4jWkw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
@ -1730,16 +1730,16 @@
}
},
"node_modules/@mui/system": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz",
"integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-9.4.0.tgz",
"integrity": "sha512-AZz9z13oGS1J0BYeAFHTbV7PegCtPiE/uMEIi7TJ1kTx3Vkt+jUpLyXF/2uV8B6Y79iTh98kVeOH9fX3x3zXXg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/private-theming": "^9.2.0",
"@mui/styled-engine": "^9.1.1",
"@mui/types": "^9.1.1",
"@mui/utils": "^9.2.0",
"@babel/runtime": "^7.29.7",
"@mui/private-theming": "^9.4.0",
"@mui/styled-engine": "^9.4.0",
"@mui/types": "^9.4.0",
"@mui/utils": "^9.4.0",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
@ -1770,12 +1770,12 @@
}
},
"node_modules/@mui/types": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz",
"integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-9.4.0.tgz",
"integrity": "sha512-13DH0Oniua4WmGqeYbQAZqmiN7r4nfd0zwIoGJ5QeJwyIHgEmf+LkRw/jV1HZb6AiUoHX8Oxf4xgalq3vYHcIw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
"@babel/runtime": "^7.29.7"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
@ -1787,17 +1787,17 @@
}
},
"node_modules/@mui/utils": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz",
"integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==",
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.4.0.tgz",
"integrity": "sha512-WkzY8uYtqUDyGKPShrRQRBomfMQHddlsyKu/gRRESYO24tDPD2z0xRE8CBoUsWqpBa+YrFs9KixiS7kQRjsPAQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/types": "^9.1.1",
"@babel/runtime": "^7.29.7",
"@mui/types": "^9.4.0",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.6"
"react-is": "^19.2.8"
},
"engines": {
"node": ">=14.0.0"
@ -2157,30 +2157,30 @@
"license": "MIT"
},
"node_modules/@sentry/browser": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz",
"integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.2.tgz",
"integrity": "sha512-2Q8Bd6aTIHmqWL/rGDZdr1JTGW2fYPXf+lMyhHp4CX6OE7gMaaI5ZUwQWgVelqUOvKkOZHHk0wo6K8+ExdwawQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/browser-utils": "10.75.2",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0",
"@sentry/feedback": "10.75.0",
"@sentry/replay": "10.75.0",
"@sentry/replay-canvas": "10.75.0"
"@sentry/core": "10.75.2",
"@sentry/feedback": "10.75.2",
"@sentry/replay": "10.75.2",
"@sentry/replay-canvas": "10.75.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz",
"integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.2.tgz",
"integrity": "sha512-U9RIJE0vZHWEnfEpJAa/SQ60MsNSUaxww4MVymNLwJ26BrLFz9jLhVIW9bXcx0i1iwK+KQbIv6ebPDOnCfYUPQ==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
"@sentry/core": "10.75.2"
},
"engines": {
"node": ">=18"
@ -2379,9 +2379,9 @@
}
},
"node_modules/@sentry/core": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz",
"integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz",
"integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
@ -2391,26 +2391,26 @@
}
},
"node_modules/@sentry/feedback": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz",
"integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.2.tgz",
"integrity": "sha512-/nrtMVSxOqBAigMWxIQS+Y8OqQ27ty+isMh6+X96kfAp/SaGrNZ425SZxKwq0mwHS0WnVkF+rE5dJnbbi56XSA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0"
"@sentry/core": "10.75.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz",
"integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.2.tgz",
"integrity": "sha512-4MtB1tSmMHzwTwY7m30UzRbbUx9cP84UK7WIDL9g7RvoGd/aZwxbp6IkUJ46VBNItVoSF+1GM9wtqzkLma252g==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.75.0",
"@sentry/browser": "10.75.2",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
"@sentry/core": "10.75.2"
},
"engines": {
"node": ">=18"
@ -2420,26 +2420,26 @@
}
},
"node_modules/@sentry/replay": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz",
"integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.2.tgz",
"integrity": "sha512-cCfdPt892S+rrAFOuUvbwR318/ZPFfBFvVkMlL3goktAnbECAGy6comAxLQEalyp22Ee67bEqYQN7RdNuRHIJw==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/core": "10.75.0"
"@sentry/browser-utils": "10.75.2",
"@sentry/core": "10.75.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz",
"integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==",
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.2.tgz",
"integrity": "sha512-4GVhCkn+/0QEpXdY8UfXahg8qjF2QTH1+qej/CLketHGfRqxLhdanzHU9ILesplWX5GeuTkPn5ZTmd5CJf+KXg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0",
"@sentry/replay": "10.75.0"
"@sentry/core": "10.75.2",
"@sentry/replay": "10.75.2"
},
"engines": {
"node": ">=18"

View file

@ -59,9 +59,9 @@
"@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "9.2.0",
"@mui/material": "9.2.0",
"@sentry/react": "10.75.0",
"@mui/icons-material": "9.4.0",
"@mui/material": "9.4.0",
"@sentry/react": "10.75.2",
"@tanstack/query-broadcast-client-experimental": "5.103.1",
"@tanstack/react-query": "5.103.1",
"@tanstack/react-query-devtools": "5.103.1",
@ -104,7 +104,7 @@
"prettier": "^3.9.8",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"vitest": "^4.1.11"
},

View file

@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<SlaAtRiskBanner />
<Box
component="main"

View file

@ -0,0 +1,71 @@
import { useNavigate } from "react-router";
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
unassignedDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
interface DashboardKpiCardsProps {
stats?: DashboardStats;
loading: boolean;
range: DashboardRangeKey;
}
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
const navigate = useNavigate();
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
const pendingUplifts = stats?.pendingUplifts ?? 0;
const avetaPending = stats?.avetaPending ?? 0;
const unassigned = stats?.unassigned ?? 0;
return (
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats?.scheduledTomorrow}
loading={loading}
active={scheduledTomorrow > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats?.pendingUplifts}
loading={loading}
active={pendingUplifts > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats?.avetaPending}
loading={loading}
active={avetaPending > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
<DashboardKpiCard
title="Unassigned"
icon={<AssignmentLateOutlinedIcon />}
tone="warning"
value={stats?.unassigned}
loading={loading}
active={unassigned > 0}
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
/>
</Box>
);
}

View file

@ -1,11 +1,7 @@
import { useNavigate } from "react-router";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
export default function DashboardPage() {
const navigate = useNavigate();
const scope = useDashboardScope();
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
@ -62,35 +51,7 @@ export default function DashboardPage() {
/>
}
/>
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats.data?.scheduledTomorrow}
loading={stats.isLoading}
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats.data?.pendingUplifts}
loading={stats.isLoading}
active={(stats.data?.pendingUplifts ?? 0) > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats.data?.avetaPending}
loading={stats.isLoading}
active={(stats.data?.avetaPending ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
</Box>
<DashboardKpiCards stats={stats.data} loading={stats.isLoading} range={range} />
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkOrderBreakdownCard
breakdown={stats.data?.breakdown ?? []}

View file

@ -4,3 +4,7 @@ export function formatUpliftMoney(value: number): string {
maximumFractionDigits: 2,
})}`;
}
export function isGrantedAmount(value: number | null): value is number {
return value != null && value > 0;
}

View file

@ -12,9 +12,13 @@ import {
TableRow,
Tooltip,
} from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
@ -100,7 +104,7 @@ function PendingRowActions({
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(row))}
</Button>
</span>
</Tooltip>
@ -211,11 +215,11 @@ function UpliftApprovalRow({
</TableCell>
<TableCell>
<Text variant="body" className="font-medium">
{formatUpliftMoney(row.requestedNTE)}
{formatUpliftMoney(upliftAmount(row))}
</Text>
</TableCell>
<TableCell>
{row.approvedOnWoTotal != null ? (
{isGrantedAmount(row.approvedOnWoTotal) ? (
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
) : (
<Text variant="caption" tone="muted">

View file

@ -1,5 +1,6 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
@ -23,7 +24,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
amount={rejectTarget ? upliftAmount(rejectTarget) : undefined}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
@ -31,7 +32,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
amount={revokeTarget ? upliftAmount(revokeTarget) : undefined}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}

View file

@ -16,10 +16,14 @@ import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
@ -142,6 +146,7 @@ function ApprovedOnWoBreakdown({
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
const grantedTotal = isGrantedAmount(breakdown.total) ? breakdown.total : null;
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
@ -156,7 +161,12 @@ function ApprovedOnWoBreakdown({
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
<BreakdownRow
label="Total"
value={grantedTotal}
strong
unavailable={unavailable && breakdown.total == null}
/>
</Box>
);
}
@ -272,7 +282,7 @@ function PendingModalActions({
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(item))}
</Button>
</span>
</Tooltip>
@ -385,7 +395,7 @@ function UpliftDetailContent({
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
{formatUpliftMoney(upliftAmount(item))}
</Text>
</Box>
<WorkOrderSection item={item} />

View file

@ -5,10 +5,12 @@ import {
} from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits";
type SlideOverCompletionMediaProps = {
isReadOnly: boolean;
media: MediaFile[];
mediaCounts?: WorkOrderMediaKindCounts;
mediaPending: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = {
export function SlideOverCompletionMedia({
isReadOnly,
media,
mediaCounts,
mediaPending,
onUploadMedia,
onCategorizeMedia,
@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
mediaCounts={mediaCounts}
onUpload={onUploadMedia}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}

View file

@ -12,6 +12,7 @@ import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
@ -23,6 +24,7 @@ type SlideOverCompletionTabProps = {
isReadOnly: boolean;
completion?: WorkOrderBoardCompletion;
media: MediaFile[];
mediaCounts?: WorkOrderMediaKindCounts;
mediaPending?: boolean;
completionPending?: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
@ -39,6 +41,7 @@ export function SlideOverCompletionTab({
isReadOnly,
completion,
media,
mediaCounts,
mediaPending = false,
completionPending = false,
onUploadMedia,
@ -59,6 +62,7 @@ export function SlideOverCompletionTab({
<SlideOverCompletionMedia
isReadOnly={isReadOnly}
media={media}
mediaCounts={mediaCounts}
mediaPending={mediaPending}
onUploadMedia={onUploadMedia}
onCategorizeMedia={onCategorizeMedia}

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import {
File as FileIcon,
FileText,
@ -18,12 +18,16 @@ import {
hasExplicitAvetaFile,
resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
countWorkOrderMediaKinds,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
workOrderExtraDocsAcceptAttribute,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
@ -53,11 +57,16 @@ type SlideOverExtrasTabProps = {
pending?: boolean;
avetaRequired?: boolean;
workOrderId?: string | number;
/** Photos/videos on the whole work order; Extra Docs shows only its own share. */
mediaCounts?: WorkOrderMediaKindCounts;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
const UNSUPPORTED_TYPE_MESSAGE =
"Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.";
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
const tab = window.open("about:blank", "_blank");
if (!tab) {
@ -176,27 +185,38 @@ export function SlideOverExtrasTab({
pending = false,
avetaRequired = false,
workOrderId,
mediaCounts,
onUpload,
onRemove,
onCategorize,
}: 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 = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderExtraDocTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
const addFiles = async (files: FileList | null) => {
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length > 0) {
onUpload(accepted);
}
@ -228,12 +248,12 @@ export function SlideOverExtrasTab({
<>
<button
type="button"
disabled={pending}
disabled={busy}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
void addFiles(event.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
style={{
@ -245,13 +265,13 @@ 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, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
per file
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
MB and 90 s · Docs up to 50 MB
</span>
</button>
<input
@ -260,9 +280,9 @@ export function SlideOverExtrasTab({
accept={workOrderExtraDocsAcceptAttribute()}
multiple
hidden
disabled={pending}
disabled={busy}
onChange={(event) => {
addFiles(event.target.files);
void addFiles(event.target.files);
event.target.value = "";
}}
/>

View file

@ -17,6 +17,7 @@ import {
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -130,6 +131,9 @@ export function useSlideOverMediaState({
[detailForRow?.media, ownership, completionHasGallery],
);
// Per-work-order photo/video counts span both tabs, so count the whole session.
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
@ -196,6 +200,7 @@ export function useSlideOverMediaState({
return {
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,

View file

@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({
const {
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({
...readFlags,
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
completionPending,
closeDisabled,

View file

@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaCounts={state.mediaCounts}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
mediaCounts={state.mediaCounts}
workOrderId={infoSource?.id}
avetaRequired={resolveSlideOverAvetaRequired(
state.editing,

View file

@ -27,7 +27,12 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
key={option.key}
label={option.label}
selected={draft.dateRange === option.key}
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
onClick={() =>
setDraft((prev) => ({
...prev,
dateRange: prev.dateRange === option.key ? null : option.key,
}))
}
/>
))}
<AdvFilterPill

View file

@ -1,7 +1,11 @@
import type { RefObject } from "react";
import { Upload } from "lucide-react";
import {
formatMediaSizeLimit,
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
MAX_WORK_ORDER_PHOTO_BYTES,
MAX_WORK_ORDER_VIDEO_BYTES,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
@ -12,6 +16,12 @@ type MediaUploaderDropzoneProps = {
onFilesSelected: (files: FileList | null) => void;
};
function mediaContractHint(): string {
const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000);
const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000);
return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`;
}
export function MediaUploaderDropzone({
inputRef,
pending,
@ -41,7 +51,7 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
{categorizeAfterUpload ? " · categorize after upload" : ""}
</span>
</button>

View file

@ -1,14 +1,18 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
countWorkOrderMediaKinds,
isWorkOrderMediaTypeAllowed,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
@ -30,8 +34,12 @@ type MediaUploaderProps = {
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
/** Photos/videos on the whole work order when `uploads` is only one surface's share. */
mediaCounts?: WorkOrderMediaKindCounts;
};
const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV.";
export function MediaUploader({
uploads,
onUpload,
@ -42,24 +50,35 @@ export function MediaUploader({
onChange,
readOnly = false,
pending = false,
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 = (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return;
const addFiles = async (files: FileList | null) => {
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
// 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);
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderMediaTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length === 0) return;
if (onUpload) {
@ -93,9 +112,9 @@ export function MediaUploader({
{!readOnly && (
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending}
pending={pending || screening}
categorizeAfterUpload={Boolean(onCategorize)}
onFilesSelected={addFiles}
onFilesSelected={(files) => void addFiles(files)}
/>
)}
<MediaUploaderGrid

View file

@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
import {
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
getInitialAdvancedFilters,
resolveAppliedAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
};
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
setAdvApplied(cloneAdvancedFilters(filters));
setAdvApplied(resolveAppliedAdvancedFilters(filters));
setPage(0);
};

View file

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

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { formatDateTime } from "@/lib/time-utils";
@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
onWithdraw: (request: VendorPortalUpliftRequest) => void;
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
onCancelRevise: () => void;
mediaUsage?: VendorMediaUsage;
};
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
@ -23,6 +25,21 @@ function statusClassName(status: string): string {
return `vp-uplift-status-${String(status).toLowerCase()}`;
}
function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) {
// Requests raised from the work order are visible but read-only to the vendor.
const actionable = !locked && canAct && request.raisedByVendor !== false;
return {
canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status),
canRevise: actionable && request.status === "ChangesRequested",
};
}
// A vendor request stores the requested new NTE total; a work-order request stores the increase.
function requestedTotal(request: VendorPortalUpliftRequest): number {
const requested = Number(request.requestedNTE);
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;
}
export function UpliftRequestCard({
request,
locked,
@ -33,18 +50,17 @@ export function UpliftRequestCard({
onWithdraw,
onRevise,
onCancelRevise,
mediaUsage,
}: UpliftRequestCardProps) {
const [revising, setRevising] = useState(false);
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
const canRevise = !locked && canAct && request.status === "ChangesRequested";
const { canWithdraw, canRevise } = availableActions(request, locked, canAct);
return (
<div className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
${requestedTotal(request).toFixed(2)} (was ${Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
@ -100,6 +116,7 @@ export function UpliftRequestCard({
initialRequested={String(request.requestedNTE)}
initialReason={request.vendorReason ?? ""}
hasExistingEvidence={request.evidenceDocumentId != null}
mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={uploadingPercent}
errorMessage=""

View file

@ -1,6 +1,10 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { Text } from "@/components/ui/text";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import {
validateVendorDocument,
VENDOR_UPLOAD_ACCEPT,
type VendorMediaUsage,
} from "@/domain/vendor-portal/lib/document-validation";
import {
isApprovalRequired,
validateUpliftAmount,
@ -13,6 +17,8 @@ type UpliftRequestFormProps = {
initialRequested?: string;
initialReason?: string;
hasExistingEvidence?: boolean;
/** Work-order photo/video usage for the evidence pre-check. */
mediaUsage?: VendorMediaUsage;
submitting: boolean;
uploadingPercent: number | undefined;
errorMessage: string;
@ -26,6 +32,7 @@ export function UpliftRequestForm({
initialRequested = "",
initialReason = "",
hasExistingEvidence = false,
mediaUsage,
submitting,
uploadingPercent,
errorMessage,
@ -36,11 +43,15 @@ export function UpliftRequestForm({
const [reason, setReason] = useState(initialReason);
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
const [formError, setFormError] = useState("");
const [checkingEvidence, setCheckingEvidence] = useState(false);
const latestSelection = useRef(0);
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
const submit = (event: React.FormEvent) => {
event.preventDefault();
// The picked file is not in state until its check settles; submitting now would validate the old one.
if (checkingEvidence) return;
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
file: evidenceFile,
@ -54,10 +65,15 @@ export function UpliftRequestForm({
onSubmit({ amount, reason: reason.trim(), evidenceFile });
};
const chooseFile = (file?: File) => {
const validationMessage = file ? validateVendorDocument(file) : undefined;
const chooseFile = async (file?: File) => {
const selection = ++latestSelection.current;
setCheckingEvidence(Boolean(file));
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
// A newer pick may have settled while this one's video metadata was still loading.
if (selection !== latestSelection.current) return;
setFormError(validationMessage ?? "");
setEvidenceFile(file);
setCheckingEvidence(false);
};
const shownError = errorMessage || formError;
@ -112,14 +128,16 @@ export function UpliftRequestForm({
</div>
<div className="vp-row">
<strong>
<label htmlFor={`uplift-evidence-${mode}`}>Evidence (PDF/JPG/PNG)</label>
<label htmlFor={`uplift-evidence-${mode}`}>
Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)
</label>
</strong>
<input
id={`uplift-evidence-${mode}`}
type="file"
className="vp-input"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
onChange={(event) => chooseFile(event.target.files?.[0])}
accept={VENDOR_UPLOAD_ACCEPT}
onChange={(event) => void chooseFile(event.target.files?.[0])}
/>
</div>
</>
@ -134,8 +152,14 @@ export function UpliftRequestForm({
{shownError}
</Text>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy || checkingEvidence}>
{busy
? "Submitting…"
: checkingEvidence
? "Checking evidence…"
: mode === "revise"
? "Submit revision"
: "Request uplift"}
</button>
{mode === "revise" && (
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>

View file

@ -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 = {
@ -74,7 +77,7 @@ function useVendorUpliftActions({
if (requiresApproval) {
if (!values.evidenceFile) {
setFormError(
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
"Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.",
);
return;
}
@ -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}

View file

@ -3,17 +3,83 @@ 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,
VENDOR_UPLOAD_ACCEPT,
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 +90,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,18 +120,27 @@ export function VendorPortalDocuments({
});
const chooseFile = (documentId?: number) => {
selectionRef.current += 1;
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
inputRef.current?.click();
};
const uploadSelectedFile = (file?: File) => {
const uploadSelectedFile = async (file?: File) => {
if (!file) {
return;
}
const validationMessage = validateVendorDocument(file);
const selection = ++selectionRef.current;
const replaced = documents.find((document) => document.id === replacementId);
const validationMessage = await validateVendorDocument(
file,
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
);
if (selection !== selectionRef.current) {
return;
}
if (validationMessage) {
setValidationError(validationMessage);
return;
@ -83,9 +161,9 @@ export function VendorPortalDocuments({
ref={inputRef}
type="file"
aria-label="Choose completion document"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
accept={VENDOR_UPLOAD_ACCEPT}
hidden
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
/>
{documents.length === 0 && (
@ -93,45 +171,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 && (
@ -155,6 +202,10 @@ export function VendorPortalDocuments({
>
Upload document
</button>
<span className="vp-hint">
Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and
90 s · PDFs up to 10 MB
</span>
</div>
)}
</>

View file

@ -190,6 +190,13 @@
margin-top: 12px;
}
.vp-hint {
align-self: center;
flex-basis: 100%;
font-size: 12px;
color: var(--muted-foreground);
}
.vp-checklist-item {
display: flex;
align-items: center;

View file

@ -1,3 +1,4 @@
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
return (
<>

View file

@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
onAcknowledge={center.acknowledge}
acknowledging={
item.target.workOrderId != null &&
item.target.workOrderId === center.acknowledgingWorkOrderId
}
/>
))}
</ul>

View file

@ -53,8 +53,37 @@ type NotificationRowProps = {
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
onAcknowledge?: (item: NotificationItem) => void;
acknowledging?: boolean;
};
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
function AcknowledgeButton({
item,
onAcknowledge,
acknowledging = false,
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
return (
<button
type="button"
onClick={() => onAcknowledge?.(item)}
disabled={acknowledging}
title="Acknowledge"
aria-label="Acknowledge SLA breach"
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
color: "var(--destructive)",
borderColor: "var(--destructive)",
}}
>
{acknowledging ? "Acknowledging…" : "Acknowledge"}
</button>
);
}
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
export function NotificationRow({
item,
unread,
onOpen,
onDismiss,
onAcknowledge,
acknowledging,
}: NotificationRowProps) {
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
<X size={13} aria-hidden />
</button>
)}
{item.rowType === "acknowledge" && (
<AcknowledgeButton
item={item}
onAcknowledge={onAcknowledge}
acknowledging={acknowledging}
/>
)}
</li>
);
}

View file

@ -0,0 +1,90 @@
import { useEffect, useMemo } from "react";
import { AlertTriangle, ChevronRight, X } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
const { claimSlaToasts } = useNotificationSession();
useEffect(() => {
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
atRisk
.filter((workOrder) => claimed.has(workOrder.id))
.forEach((workOrder) =>
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
);
}, [atRisk, claimSlaToasts]);
}
/**
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
* hides them for this session, and a work order that becomes at risk later brings the banner back.
*/
export function SlaAtRiskBanner() {
const navigate = useNavigate();
const { data } = useNotificationFeed();
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
useSlaHalfwayToasts(atRisk);
const shown = useMemo(
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
[atRisk, slaBannerDismissedIds],
);
if (shown.length === 0) {
return null;
}
const ids = shown.map((workOrder) => workOrder.id);
return (
<div
role="region"
aria-label="Work orders at risk of missing their response deadline"
className="flex shrink-0 items-center"
style={{
gap: 10,
padding: "6px 16px",
background: "var(--destructive)",
color: "var(--destructive-foreground)",
}}
>
<button
type="button"
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
title="View in Work Orders"
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
style={{ color: "inherit" }}
>
<span
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
>
<AlertTriangle size={14} className="shrink-0" aria-hidden />
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
{slaBannerText(shown)}
</span>
<ChevronRight size={14} className="shrink-0" aria-hidden />
</span>
</button>
<button
type="button"
onClick={() => dismissSlaBanner(ids)}
title="Dismiss"
aria-label="Dismiss SLA alert"
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
style={{ width: 22, height: 22, color: "inherit" }}
>
<X size={14} aria-hidden />
</button>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo } from "react";
import { useNavigate } from "react-router";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) {
[items, readIds],
);
const acknowledgeBreach = useAcknowledgeSlaBreach();
const acknowledge = (item: NotificationItem) => {
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
acknowledgeBreach.mutate(item.target.workOrderId);
};
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
markRead([item.id]);
if (url == null) return;
@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
acknowledge,
acknowledgingWorkOrderId,
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};

View file

@ -251,6 +251,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")),
unassigned: toNumber(readField(data, "unassigned")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
};

View file

@ -20,6 +20,8 @@ export interface DashboardStats {
scheduledTomorrow: number;
pendingUplifts: number;
avetaPending: number;
/** Open WOs with no dispatcher in the selected period. */
unassigned: number;
breakdown: DashboardBreakdownRow[];
statusDistribution: DashboardStatusBucketRow[];
}

View file

@ -1,6 +1,7 @@
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
addDaysIso,
businessTodayIso,
@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch(
return search;
}
/**
* The Unassigned tile's list: open WOs with no dispatcher in the
* Dashboard period, the same set the backend counts. All time sends no dates,
* so the board searches every week, undated WOs included.
*/
export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return search;
}
export function scheduledTomorrowDrilldownSearch(
today: string = businessTodayIso(),
): URLSearchParams {

View file

@ -1,4 +1,4 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPostNoContent } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
@ -7,4 +7,7 @@ import {
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
/** Records who acknowledged the missed response deadline in the work order's audit history. */
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
};

View file

@ -5,6 +5,11 @@ export type NotificationSession = {
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
/** At-risk work orders hidden from the SLA banner for this session. */
slaBannerDismissedIds: ReadonlySet<number>;
dismissSlaBanner: (workOrderIds: number[]) => void;
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
claimSlaToasts: (workOrderIds: number[]) => number[];
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);

View file

@ -1,7 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
if (ids.every((id) => current.has(id))) {
return current;
}
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
() => new Set(),
);
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
const toastedSlaIds = useRef(new Set<number>());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
}, []);
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
claimed.forEach((id) => toastedSlaIds.current.add(id));
return claimed;
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
() => ({
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
}),
[
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
],
);
return (

View file

@ -50,9 +50,21 @@ const sectionSchema = z.object({
items: z.array(itemSchema),
});
/** A work order past half of its SEV response window, with the server-computed clock. */
const slaAtRiskSchema = z.object({
id: z.number(),
number: z.string().nullish(),
severity: z.number(),
startedAt: z.string(),
deadlineAt: z.string(),
percentElapsed: z.number(),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
// Defaulted so a feed from a server without SLA alerts still parses.
slaAtRisk: z.array(slaAtRiskSchema).default([]),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
export type NotificationItem = z.infer<typeof itemSchema>;
export type NotificationSection = z.infer<typeof sectionSchema>;
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;

View file

@ -0,0 +1,44 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import type {
NotificationFeed,
NotificationItem,
} from "@/domain/notifications/types/notification-feed";
import { queryKeys } from "@/infra/query-key/query-key";
function isBreachRowFor(workOrderId: number) {
return (item: NotificationItem) =>
item.reason === "sla" &&
item.rowType === "acknowledge" &&
item.target.workOrderId === workOrderId;
}
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
const matches = isBreachRowFor(workOrderId);
return {
...feed,
sections: feed.sections.map((section) => {
const removed = section.items.filter(matches);
if (removed.length === 0) return section;
return {
...section,
items: section.items.filter((item) => !matches(item)),
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
};
}),
};
}
export function useAcknowledgeSlaBreach() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
onSuccess: (_result, workOrderId) => {
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
feed ? withoutSlaBreach(feed, workOrderId) : feed,
);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
});
}

View file

@ -0,0 +1,20 @@
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
return workOrder.number != null && workOrder.number !== ""
? `WO #${workOrder.number}`
: `WO ${workOrder.id}`;
}
/** One line whatever the count, as the top-of-app banner shows it. */
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
if (workOrders.length === 1) {
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
}
return `${workOrders.length} work orders are at risk of missing their response deadline`;
}
/** The one-time toast when a work order is first seen past half of its response window. */
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
}

View file

@ -5,6 +5,7 @@ import {
useRejectUplift,
useRevokeUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
export function useUpliftApprovalController() {
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
@ -15,7 +16,7 @@ export function useUpliftApprovalController() {
const revokeUplift = useRevokeUplift();
const handleApprove = (row: UpliftQueueItem) => {
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
approveUplift.mutate({ id: row.id, amount: upliftAmount(row), woNumber: row.woNumber });
setDetailItem(null);
};
@ -35,7 +36,7 @@ export function useUpliftApprovalController() {
{
id: rejectTarget.id,
note: reason,
amount: rejectTarget.delta,
amount: upliftAmount(rejectTarget),
woNumber: rejectTarget.woNumber,
},
{ onSuccess: () => setRejectTarget(null) },
@ -48,7 +49,7 @@ export function useUpliftApprovalController() {
{
upliftId: revokeTarget.id,
reason,
amount: revokeTarget.delta,
amount: upliftAmount(revokeTarget),
woNumber: revokeTarget.woNumber,
},
{ onSuccess: () => setRevokeTarget(null) },

View file

@ -0,0 +1,11 @@
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
/**
* The amount of an uplift: the increase being requested on top of the work
* order's current NTE. Every surface that names "the uplift" (queue Amount,
* Approve label, detail modal, reject/revoke dialogs and decision toasts)
* shows this figure; `requestedNTE` is the resulting NTE total, not the uplift.
*/
export function upliftAmount(request: Pick<UpliftRequest, "delta">): number {
return request.delta;
}

View file

@ -1,12 +1,129 @@
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
export function validateVendorDocument(file: File): string | undefined {
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
return "Only PDF, JPG, and PNG files are allowed.";
/**
* Client-confirmed media contract on the vendor portal: photos up to
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
* PDF documents keep the previous 10 MB document cap.
*/
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
const MAX_PHOTO_BYTES = 10_000_000;
const MAX_VIDEO_BYTES = 100_000_000;
const MAX_VIDEO_DURATION_SECONDS = 90;
const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.";
/** File-picker filter for every vendor portal upload, matching the allowlist below. */
export const VENDOR_UPLOAD_ACCEPT =
".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime";
type VendorUploadKind = "document" | "photo" | "video";
// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
// extension decides (mobile browsers often send an empty or octet-stream type).
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
"application/pdf": "document",
"image/jpeg": "photo",
"image/jpg": "photo",
"image/png": "photo",
"image/heic": "photo",
"video/mp4": "video",
"video/quicktime": "video",
};
const KIND_BY_EXTENSION: Record<string, VendorUploadKind> = {
pdf: "document",
jpg: "photo",
jpeg: "photo",
png: "photo",
heic: "photo",
mp4: "video",
mov: "video",
};
function resolveKind(file: File): VendorUploadKind | null {
const name = file.name.toLowerCase();
const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
}
/** Photos/videos already counted against the work order for one upload, with the limits. */
export type VendorMediaUsage = {
photos: number;
videos: number;
maxPhotos: number;
maxVideos: number;
};
/**
* The count basis the server uses for this upload. A completion upload replaces a
* document: the latest one by default (already excluded in `completion*`), or the
* one the vendor picked, which is not counted when it is the same kind. Evidence
* uploads count everything. Undefined when the backend does not report counts.
*/
export function vendorMediaUsage(
counts: VendorPortalMediaCounts | null | undefined,
purpose: "completion" | "evidence",
replacedContentType?: string,
): VendorMediaUsage | undefined {
if (!counts) return undefined;
const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
if (purpose === "evidence") {
return { ...limits, photos: counts.photos, videos: counts.videos };
}
if (replacedContentType === undefined) {
return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
}
const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
return {
...limits,
photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
};
}
function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
if (kind === "photo" && usage.photos >= usage.maxPhotos) {
return `A work order can have at most ${usage.maxPhotos} photos.`;
}
if (kind === "video" && usage.videos >= usage.maxVideos) {
return `A work order can have at most ${usage.maxVideos} videos.`;
}
return undefined;
}
export async function validateVendorDocument(
file: File,
usage?: VendorMediaUsage,
): Promise<string | undefined> {
const kind = resolveKind(file);
if (!kind) {
return UNSUPPORTED_TYPE_MESSAGE;
}
if (file.size > MAX_DOCUMENT_BYTES) {
const countMessage = usage && countRejection(kind, usage);
if (countMessage) {
return countMessage;
}
if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
return "Photos must be 10 MB or smaller.";
}
if (kind === "video") {
if (file.size > MAX_VIDEO_BYTES) {
return "Videos must be 100 MB or smaller.";
}
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
const duration = await probeVideoDurationSeconds(file);
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
}
}
if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) {
return "Files must be 10 MB or smaller.";
}
return undefined;
}

View file

@ -48,7 +48,7 @@ export function validateUpliftAmount(
return "Please include a reason for the uplift request.";
}
if (!options.file && !options.hasExisting) {
return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.";
}
return "";
}

View file

@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
refusedAt?: string;
}
/** Counts for a new completion version exclude the document it replaces. */
export interface VendorPortalMediaCounts {
maxPhotos: number;
maxVideos: number;
photos: number;
videos: number;
completionPhotos: number;
completionVideos: number;
}
export interface VendorPortalDocument {
id: number;
originalFileName: string;
@ -66,6 +76,8 @@ export interface VendorPortalUpliftRequest {
requestedNTE: number;
currentNTE?: number;
vendorReason?: string;
/** False when the request was raised from the work order; the vendor cannot revise or withdraw it. */
raisedByVendor?: boolean;
requestedAt?: string;
decidedAt?: string;
decidedByName?: string;
@ -137,4 +149,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
/** Per-work-order photo/video usage; absent until the backend reports it. */
mediaCounts?: VendorPortalMediaCounts | null;
}

View file

@ -48,7 +48,11 @@ import {
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
import {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
buildBoardSearchParamsRecord,
} from "@/domain/work-orders/utils/board-query-params";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -223,8 +227,13 @@ export const workOrdersApi = {
"Unable to verify the work order number: search result set exceeded the safety page limit.",
);
}
// Every week plus undated rows: a WO# must be unique regardless of schedule.
const result = await workOrdersApi.searchBoard({
search: normalized,
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
includeDateless: true,
page,
pageSize,
});

View file

@ -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/")) {

View file

@ -23,7 +23,8 @@ export type DateRangeKey =
export type CompletionDocFilter = "No" | "Yes" | "NN";
export interface WorkOrderAdvancedFilters {
dateRange: DateRangeKey;
/** `null` = no date range selected: search every week. */
dateRange: DateRangeKey | null;
customFrom: string;
customTo: string;
/** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */
@ -45,10 +46,12 @@ export interface WorkOrderAdvancedFilters {
internalOnly: boolean;
hasUplift: boolean;
upliftStatuses: Set<UpliftFilterStatus>;
/** Exact work orders to show; when non-empty the server ignores every other filter. */
workOrderIds: Set<number>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dateRange: "this-week",
dateRange: null,
customFrom: "",
customTo: "",
unscheduledOnly: false,
@ -69,6 +72,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
internalOnly: false,
hasUplift: false,
upliftStatuses: new Set(),
workOrderIds: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [

View file

@ -318,9 +318,16 @@ export interface WorkOrderBoardSearchQueryParams {
* legacy wire behavior.
*/
unscheduledOnly?: boolean;
/**
* `true` adds open rows with no date to the range. Sent only when no
* date range is selected, so an explicit range strictly narrows the results.
*/
includeDateless?: boolean;
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
ids?: string;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string;
}

View file

@ -60,7 +60,7 @@ function severityLabel(level: WorkOrderSeverity): string {
}
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
if (filters.dateRange !== "this-week") {
if (filters.dateRange !== null) {
const value =
filters.dateRange === "custom"
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
@ -71,7 +71,7 @@ export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAd
value,
clear: (current) => ({
...current,
dateRange: "this-week",
dateRange: null,
customFrom: "",
customTo: "",
}),
@ -306,3 +306,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
clear: clearUplift,
});
}
export function appendWorkOrderIdsChip(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
const count = filters.workOrderIds?.size ?? 0;
if (count === 0) return;
chips.push({
key: "workOrderIds",
group: "Work orders",
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
});
}

View file

@ -2,18 +2,44 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-o
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
import {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { isTerminalCarryOverStatus } from "@/domain/work-orders/utils/carry-over-display";
import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
type AdvancedFilterPredicate = (
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
) => boolean;
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
!row.scheduledOn && !isTerminalCarryOverStatus(row.status);
// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter)
// so the page and /board/search keep the same rows: a row is in a range
// when its date is, or when it is week-only and its target week overlaps. With
// no range, every week is searched and open undated rows are added. Closed
// undated rows are never unscheduled.
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
if (filters.unscheduledOnly) {
return !row.scheduledOn;
return isOpenUndated(row);
}
const [from, to] = dateRangeFor(filters);
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
const [from, to] = dateRangeFor(filters) ?? [
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
];
if (row.scheduledOn && row.scheduledOn >= from && row.scheduledOn <= to) return true;
if (
row.scheduleWeekOnly &&
Boolean(row.targetWeek) &&
row.targetWeek <= to &&
addDaysIso(row.targetWeek, 6) >= from
) {
return true;
}
return filters.dateRange === null && isOpenUndated(row);
};
const matchesSite: AdvancedFilterPredicate = (row, filters) =>

View file

@ -12,6 +12,7 @@ import {
appendSeverityChip,
appendStatusPmVendorDocChips,
appendUpliftChip,
appendWorkOrderIdsChip,
type AdvancedFilterChip,
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@ -33,12 +34,13 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
docs: new Set(filters.docs),
flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []),
workOrderIds: new Set(filters.workOrderIds ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly),
};
}
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
filters.dateRange !== "this-week",
filters.dateRange !== null,
Boolean(filters.sites?.size),
Boolean(filters.types?.size),
Boolean(filters.severities?.size),
@ -56,12 +58,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
filters.internalOnly,
filters.hasUplift,
filters.unscheduledOnly,
Boolean(filters.workOrderIds?.size),
];
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
}
/**
* The server honours only the id set when one is sent, so an id selection and panel facets never
* combine: applying any facet replaces the selection with a normal filtered board.
*/
export function resolveAppliedAdvancedFilters(
filters: WorkOrderAdvancedFilters,
): WorkOrderAdvancedFilters {
const next = cloneAdvancedFilters(filters);
const withoutIds = { ...next, workOrderIds: new Set<number>() };
return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next;
}
function monthStart(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-01`;
}
@ -70,7 +85,9 @@ function monthEnd(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`;
}
export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] {
/** Inclusive [from, to] for the selected range; `null` when no range is selected. */
export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] | null {
if (filters.dateRange === null) return null;
const today = todayIso();
const ty = Number(today.slice(0, 4));
const tm = Number(today.slice(5, 7)) - 1;
@ -118,6 +135,7 @@ export function buildAdvancedFilterChips(
appendIndicatorChips(chips, filters);
appendFlagAndInternalChips(chips, filters);
appendUpliftChip(chips, filters);
appendWorkOrderIdsChip(chips, filters);
return chips;
}
@ -144,6 +162,6 @@ export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrd
return cloneFilters(filters);
}
export function isDefaultDateRange(dateRange: DateRangeKey): boolean {
return dateRange === "this-week";
export function isDefaultDateRange(dateRange: DateRangeKey | null): boolean {
return dateRange === null;
}

View file

@ -1,8 +1,5 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import {
BOARD_DISPATCHER_UNASSIGNED,
@ -15,6 +12,7 @@ import {
} from "@/domain/work-orders/types/work-order-board";
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter";
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
@ -136,6 +134,13 @@ function mapSearchFacetParams(
};
}
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
// Normalized here too, so a caller that fills the set directly still stays within the server's limit.
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
return normalized.length > 0 ? normalized.join(",") : undefined;
}
/** Build GET /board query from weekly filter bar. */
export function toBoardQueryParams(
bar: WorkOrderBarFilters,
@ -158,17 +163,21 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
datePreset?: WorkOrderBoardSearchQueryParams["datePreset"];
dateFrom?: string;
dateTo?: string;
includeDateless?: boolean;
} {
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
// client must not fake a Custom window and sends no date params.
if (filters.unscheduledOnly) {
return {};
}
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
// No range selected searches every week, undated rows included. Any
// selected range (This week included) strictly narrows the results.
if (filters.dateRange === null) {
return {
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
includeDateless: true,
};
}
@ -213,6 +222,7 @@ export function toBoardSearchQueryParams(input: {
datePreset: date.datePreset,
dateFrom: date.dateFrom,
dateTo: date.dateTo,
includeDateless: date.includeDateless,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
@ -224,6 +234,7 @@ export function toBoardSearchQueryParams(input: {
docStatuses: mapDocStatuses(filters.docs),
unscheduledOnly: filters.unscheduledOnly || undefined,
...mapSearchFacetParams(filters),
ids: mapWorkOrderIds(filters.workOrderIds),
sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc",
};
@ -267,5 +278,6 @@ export function toUnassignedQueueQueryParams(
datePreset: "Custom",
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
dateTo: UNASSIGNED_QUEUE_DATE_TO,
includeDateless: true,
};
}

View file

@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter";
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
// An exact id set replaces every other filter: nothing else may hide one of those work orders.
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
if (workOrderIds.length > 0) {
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
}
const dateFrom = (params.get("dateFrom") ?? "").trim();
const dateTo = (params.get("dateTo") ?? "").trim();
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);

View file

@ -0,0 +1,64 @@
import {
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
resolveWorkOrderUploadKind,
workOrderFileSizeRejection,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
export type WorkOrderUploadScreen = {
/** Photos and videos already on the work order, across every surface. */
existing: WorkOrderMediaKindCounts;
isTypeAllowed: (file: File) => boolean;
unsupportedTypeMessage: string;
};
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
/**
* Browser pre-check shared by every dispatcher upload surface: type, size,
* per-work-order photo/video count (including earlier files in the same selection)
* and video duration. The server enforces the same contract; a video whose duration
* the browser cannot read is never blocked here.
*/
export async function screenWorkOrderUploads(
files: readonly File[],
{ existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen,
): Promise<WorkOrderUploadScreenResult> {
const counts = { ...existing };
const accepted: File[] = [];
const rejections: string[] = [];
for (const file of files) {
if (!isTypeAllowed(file)) {
rejections.push(unsupportedTypeMessage);
continue;
}
const sizeRejection = workOrderFileSizeRejection(file);
if (sizeRejection) {
rejections.push(sizeRejection);
continue;
}
const kind = resolveWorkOrderUploadKind(file);
if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) {
rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`);
continue;
}
if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) {
rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`);
continue;
}
if (kind === "video") {
const duration = await probeVideoDurationSeconds(file);
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`);
continue;
}
}
if (kind === "photo") counts.photos += 1;
if (kind === "video") counts.videos += 1;
accepted.push(file);
}
return { accepted, rejections };
}

View file

@ -0,0 +1,26 @@
/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */
export const WORK_ORDER_IDS_MAX = 200;
/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */
export function normalizeWorkOrderIds(values: Iterable<number | string>): number[] {
const ids = new Set<number>();
for (const value of values) {
const text = typeof value === "number" ? String(value) : value.trim();
if (!/^\d+$/.test(text)) continue;
const id = Number(text);
if (Number.isSafeInteger(id) && id > 0) ids.add(id);
if (ids.size === WORK_ORDER_IDS_MAX) break;
}
return [...ids];
}
/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */
export function parseWorkOrderIdsParam(raw: string | null): number[] {
return raw ? normalizeWorkOrderIds(raw.split(",")) : [];
}
/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */
export function buildWorkOrderIdsHref(ids: number[]): string {
const normalized = normalizeWorkOrderIds(ids);
return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders";
}

View file

@ -1,20 +1,38 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/** Backend RequestSizeLimit for media uploads and Extra Docs. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000;
/**
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
* the Completion Doc → Media tab and the vendor portal upload. Duration is
* pre-checked here and enforced by the server from the MP4/MOV header;
* unreadable metadata never blocks. Documents
* keep the 50 MB document cap; the signed completion PDF keeps its own cap.
*/
export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000;
export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000;
export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000;
/** Backend RequestSizeLimit for completion-document uploads. */
/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */
export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000;
/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */
export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES;
export const MAX_VIDEO_DURATION_SECONDS = 90;
export const MAX_PHOTOS_PER_WORK_ORDER = 10;
export const MAX_VIDEOS_PER_WORK_ORDER = 3;
const MEDIA_MIME_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/heic",
"video/mp4",
"video/quicktime",
]);
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]);
const EXTRA_DOC_MIME_TYPES = new Set([
...MEDIA_MIME_TYPES,
@ -25,6 +43,8 @@ const EXTRA_DOC_MIME_TYPES = new Set([
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
export type WorkOrderUploadKind = "photo" | "video" | "document";
function fileExtension(file: File): string {
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
@ -37,11 +57,58 @@ function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>):
return mimeOk || extOk;
}
export function isWorkOrderFileTooLarge(
file: File,
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
): boolean {
return file.size > maxBytes;
const KIND_BY_MIME: Record<string, WorkOrderUploadKind> = {
"image/jpeg": "photo",
"image/jpg": "photo",
"image/png": "photo",
"image/heic": "photo",
"video/mp4": "video",
"video/quicktime": "video",
};
const KIND_BY_EXTENSION: Record<string, WorkOrderUploadKind> = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".heic": "photo",
".mp4": "video",
".mov": "video",
};
/**
* Same rule as the server: an allowlisted browser type decides, otherwise the
* extension (mobile browsers often send an empty, octet-stream or foreign type).
*/
export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind {
return (
KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document"
);
}
function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number {
if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES;
if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES;
return MAX_WORK_ORDER_DOCUMENT_BYTES;
}
export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean {
const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file));
return file.size > limit;
}
/** Stable per-kind rejection message; never echoes file metadata. */
export function workOrderFileSizeRejection(file: File): string | null {
const kind = resolveWorkOrderUploadKind(file);
if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) {
return "Photos must be 10 MB or smaller.";
}
if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) {
return "Videos must be 100 MB or smaller.";
}
if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) {
return "Documents must be 50 MB or smaller.";
}
return null;
}
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
@ -53,17 +120,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
}
export function workOrderMediaAcceptAttribute(): string {
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
}
export function workOrderExtraDocsAcceptAttribute(): string {
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
}
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
export function formatMediaSizeLimit(maxBytes: number): string {
return `${Math.round(maxBytes / 1_000_000)} MB`;
}
export type WorkOrderMediaKindCounts = { photos: number; videos: number };
/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */
export function countWorkOrderMediaKinds(
uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>,
): WorkOrderMediaKindCounts {
return uploads.reduce(
(counts, item) => {
if (item.isLegacy || item.uploadStatus === "failed") return counts;
if (item.fileType === "image") counts.photos += 1;
if (item.fileType === "video") counts.videos += 1;
return counts;
},
{ photos: 0, videos: 0 },
);
}
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
return isWorkOrderCompletionSurfaceReadOnly(status);

View file

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

View file

@ -0,0 +1,75 @@
/**
* Best-effort video duration probe for MP4/MOV. Reads the movie header
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
* phone file with `moov` after a large `mdat` costs only a few small reads.
* Resolves null when the header cannot be read — callers must not block the
* upload in that case (the server enforces the limit from the same
* header and also lets unreadable metadata through).
*/
const MAX_BOXES_PER_LEVEL = 1024;
type Box = { bodyStart: number; end: number };
async function readView(file: Blob, start: number, length: number): Promise<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;
}
}

View file

@ -63,6 +63,7 @@ const statsData = {
scheduledTomorrow: 3,
pendingUplifts: 5,
avetaPending: 0,
unassigned: 4,
breakdown: [
{ status: "PM", count: 7 },
{ status: "Completed", count: 0 },
@ -88,6 +89,11 @@ function renderDashboard() {
return renderWithProviders(<DashboardPage />);
}
function lastNavigation(): string {
const calls = navigate.mock.calls;
return String(calls[calls.length - 1]?.[0]);
}
describe("DashboardPage", () => {
beforeEach(() => {
authUser.current = { id: "99", userRoles: "Admin" };
@ -120,6 +126,7 @@ describe("DashboardPage", () => {
"Scheduled Tomorrow",
"Pending Uplifts",
"Aveta Pending",
"Unassigned",
"Work Order Breakdown",
"Status Distribution",
"Dispatcher Workload",
@ -257,6 +264,29 @@ describe("DashboardPage", () => {
expect(lastRequestedPage).toBe(1);
});
it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => {
renderDashboard();
const card = screen.getByRole("button", { name: /Unassigned/ });
expect(card).toHaveTextContent("4");
fireEvent.click(card);
const weekUrl = new URL(lastNavigation(), "http://x");
expect(weekUrl.pathname).toBe("/workorders");
expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned");
expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed");
expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
fireEvent.click(screen.getByRole("button", { name: "All time" }));
fireEvent.click(screen.getByRole("button", { name: /Unassigned/ }));
const allTimeUrl = new URL(lastNavigation(), "http://x");
expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned");
expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false);
expect(allTimeUrl.searchParams.has("dateTo")).toBe(false);
});
it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso();

View file

@ -132,7 +132,7 @@ describe("Uplift Approvals approved tab", () => {
expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument();
expect(
screen.getByText(
"The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
"The approved uplift of $400.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
),
).toBeInTheDocument();
});

View file

@ -0,0 +1,189 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
// A work order that already carries an NTE: the uplift (the increase) and the
// requested NTE total differ, so any surface reading the wrong field shows up.
const UPLIFT = "$4,999,997,000.00";
const REQUESTED_NTE_TOTAL = "$5,000,000,000.00";
const api = vi.hoisted(() => ({
approve: vi.fn(),
reject: vi.fn(),
revoke: vi.fn(),
}));
const toastSuccess = vi.hoisted(() => vi.fn());
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: {
approve: (...args: unknown[]) => api.approve(...args),
reject: (...args: unknown[]) => api.reject(...args),
revoke: (...args: unknown[]) => api.revoke(...args),
},
}));
vi.mock("react-toastify", async (importOriginal) => ({
...(await importOriginal<object>()),
toast: { error: vi.fn(), success: toastSuccess, warning: vi.fn() },
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: [], isLoading: false, isError: false }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 3000,
requestedNTE: 5_000_000_000,
delta: 4_999_997_000,
vendorReason: "Full replacement",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const approvedItem: UpliftQueueItem = {
...pendingItem,
id: 42,
status: "Approved",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
canDecide: false,
};
function queueOf(item: UpliftQueueItem): UpliftsQueueResult {
return { items: [item], totalCount: 1, page: 1, pageSize: 25, pendingExposureTotal: null };
}
async function renderQueue() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function amountCell(): HTMLElement {
const row = screen.getByLabelText(/open uplift details for/i);
const header = screen.getByRole("columnheader", { name: "Amount" });
const index = Array.from(header.parentElement?.children ?? []).indexOf(header);
return row.querySelectorAll("td")[index] as HTMLElement;
}
describe("Uplift Approvals shows one amount per request", () => {
beforeEach(() => {
api.approve.mockReset().mockResolvedValue(undefined);
api.reject.mockReset().mockResolvedValue(undefined);
api.revoke.mockReset().mockResolvedValue(undefined);
toastSuccess.mockReset();
});
it("uses the uplift amount in the pending row, the detail modal and the approve toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
expect(amountCell()).toHaveTextContent(UPLIFT);
expect(screen.getByRole("button", { name: `Approve ${UPLIFT}` })).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/open uplift details for/i));
const modal = screen.getByRole("dialog");
expect(within(modal).getByText(UPLIFT)).toBeInTheDocument();
fireEvent.click(within(modal).getByRole("button", { name: `Approve ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift approved — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the reject dialog title and the reject toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(
screen.getByRole("heading", { name: `Reject uplift of ${UPLIFT}?` }),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
target: { value: "Out of scope" },
});
fireEvent.click(screen.getByRole("button", { name: "Reject uplift" }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift rejected — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the approved row, the revoke dialog and the revoke toast", async () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
expect(
screen.getByText(
`The approved uplift of ${UPLIFT} on WO WO-99 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.`,
),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for revoking/i), {
target: { value: "Approved in error" },
});
fireEvent.click(screen.getByRole("button", { name: `Revoke ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift revoked — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,160 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const queueState = vi.hoisted(() => ({ data: null as UpliftsQueueResult | null }));
const woUplifts = vi.hoisted(() => ({
data: [] as Array<Record<string, unknown>> | null,
isError: false,
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({
data: woUplifts.data,
isLoading: false,
isError: woUplifts.isError,
}),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "Extra parts",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
function seedQueue(overrides: Partial<UpliftQueueItem>) {
queueState.data = {
items: [{ ...pendingItem, ...overrides }],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: 250,
};
}
async function renderQueuePage() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function approvedOnWoColumnText(): string {
const table = screen.getByRole("table", { name: "pending uplift requests" });
const headers = within(table).getAllByRole("columnheader");
const columnIndex = headers.findIndex((header) => header.textContent === "Approved on WO");
const row = within(table).getByRole("row", { name: /Open uplift details for WO-99/ });
return within(row).getAllByRole("cell")[columnIndex]?.textContent ?? "";
}
function modalTotalText(): string {
fireEvent.click(screen.getByRole("row", { name: /Open uplift details for WO-99/ }));
const dialog = screen.getByRole("dialog");
const label = within(dialog).getByText("Total");
return label.parentElement?.lastElementChild?.textContent ?? "";
}
describe("Uplift Approvals: Approved on WO", () => {
beforeEach(() => {
woUplifts.data = [];
woUplifts.isError = false;
});
it.each([
{ total: 0, expected: "—" },
{ total: null, expected: "—" },
{ total: 125, expected: "$125.00" },
])(
"renders $expected in the column and the modal Total for total $total",
async ({ total, expected }) => {
seedQueue({ approvedOnWoTotal: total });
await renderQueuePage();
const column = approvedOnWoColumnText();
const modalTotal = modalTotalText();
expect(column).toBe(expected);
expect(modalTotal).toBe(column);
},
);
it("shows a dash in both places when the row total is unknown and the WO has nothing granted", async () => {
woUplifts.data = [
{ id: 1, status: "pending", amount: 250 },
{ id: 2, status: "rejected", amount: 80 },
{ id: 3, status: "revoked", amount: 40 },
];
seedQueue({ approvedOnWoTotal: null });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
it("keeps the modal Total as a dash when the WO uplift lookup fails but the row reports zero", async () => {
woUplifts.data = null;
woUplifts.isError = true;
seedQueue({ approvedOnWoAuto: 0, approvedOnWoAdmin: 0, approvedOnWoTotal: 0 });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
});

View file

@ -195,10 +195,10 @@ describe("UpliftDetailModal", () => {
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
.filter((label) => label === "Reject" || label === "Approve $150.00" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve $150.00"]);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
expect(onApprove).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
@ -208,7 +208,7 @@ describe("UpliftDetailModal", () => {
canApproveState.data = false;
const { onApprove, onReject } = renderModal();
const approve = screen.getByRole("button", { name: "Approve" });
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -223,7 +223,7 @@ describe("UpliftDetailModal", () => {
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Approve/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -106,7 +106,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@ -117,7 +117,7 @@ describe("Uplift Approvals decision flow", () => {
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Reject uplift of $150.00?" })).toBeInTheDocument();
expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(rejectMutate).not.toHaveBeenCalled();
@ -142,7 +142,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve" });
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -160,7 +160,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Approve $150.00" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});

View file

@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdvDateRangeSection } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { renderWithProviders } from "@/test/test-utils";
const onDraft = vi.fn<(draft: WorkOrderAdvancedFilters) => void>();
function latestDraft(): WorkOrderAdvancedFilters {
return onDraft.mock.calls[onDraft.mock.calls.length - 1][0];
}
function Harness() {
const [draft, setDraft] = useState(() => cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
useEffect(() => {
onDraft(draft);
}, [draft]);
return <AdvDateRangeSection draft={draft} setDraft={setDraft} />;
}
function openSection() {
onDraft.mockClear();
renderWithProviders(<Harness />);
const header = screen.queryByRole("button", { name: /Date range/i });
if (header && !screen.queryByRole("button", { name: /^This week$/i })) {
fireEvent.click(header);
}
}
describe("Advanced Filters date range", () => {
it("starts with no range selected, so the search spans every week", () => {
openSection();
for (const label of ["This week", "Last week", "Last 3 months", "Custom"]) {
expect(screen.getByRole("button", { name: new RegExp(`^${label}$`, "i") })).toHaveAttribute(
"aria-pressed",
"false",
);
}
expect(toBoardSearchQueryParams({ filters: latestDraft() }).includeDateless).toBe(true);
});
it("narrows to This week once picked, and clicking it again clears the range", () => {
openSection();
const thisWeek = screen.getByRole("button", { name: /^This week$/i });
fireEvent.click(thisWeek);
expect(thisWeek).toHaveAttribute("aria-pressed", "true");
const narrowed = toBoardSearchQueryParams({ filters: latestDraft() });
expect(narrowed.datePreset).toBe("ThisWeek");
expect(narrowed.includeDateless).toBeUndefined();
fireEvent.click(thisWeek);
expect(thisWeek).toHaveAttribute("aria-pressed", "false");
expect(latestDraft().dateRange).toBeNull();
});
});

View file

@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => {
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
});

View file

@ -1,19 +1,39 @@
import { createRef } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
import {
MAX_WORK_ORDER_MEDIA_BYTES,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const MB = 1_000_000;
function phoneFile(name: string, type: string, size: number): File {
return { name, type, size } as File;
}
function gallery(photos: number, videos: number): MediaFile[] {
const item = (id: string, fileType: "image" | "video") =>
({ id, fileType, isLegacy: false }) as unknown as MediaFile;
return [
...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")),
...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")),
];
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("MediaUploaderDropzone", () => {
it("matches the AAP Photos accept copy and keeps it media-only", () => {
it("shows the SH-116 media contract and keeps the picker media-only", () => {
renderWithProviders(
<MediaUploaderDropzone
inputRef={createRef<HTMLInputElement>()}
@ -23,28 +43,139 @@ describe("MediaUploaderDropzone", () => {
);
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
expect(input?.getAttribute("accept")).not.toContain(".pdf");
});
});
it("rejects an oversized media file with the generic 200 MB toast", () => {
renderWithProviders(<MediaUploader uploads={[]} />);
describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(probeVideoDurationSeconds).mockReset();
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
});
const oversizedFile = {
name: "mobile-clip.MP4",
type: "application/octet-stream",
size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
} as File;
const input = document.querySelector('input[type="file"]');
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } });
const videos = [
phoneFile("clip.mp4", "video/mp4", 60 * MB),
phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB),
phoneFile("IMG_0002.MOV", "", 60 * MB),
];
selectFiles(videos);
expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.');
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos);
expect(toast.error).not.toHaveBeenCalled();
});
it("accepts an octet-stream video with a video extension", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
});
it("rejects a 101 MB video with the generic message and no file name", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects an 11 MB photo", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects the 11th photo and the 4th video on a full work order", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(10, 3)} onUpload={onUpload} />);
selectFiles([
phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts the whole work order's media when this tab shows only its share", async () => {
const onUpload = vi.fn();
renderWithProviders(
<MediaUploader uploads={[]} mediaCounts={{ photos: 10, videos: 3 }} onUpload={onUpload} />,
);
selectFiles([
phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts files within one selection against the remaining slots", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
});
it("rejects a video longer than 90 seconds when the duration is readable", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("does not block a video whose duration the browser cannot read", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(toast.error).not.toHaveBeenCalled();
});
});

View file

@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => {
expect(getMediaContent).not.toHaveBeenCalled();
});
it("shows Extra Docs accept copy with PDF DOC and 200MB", () => {
it("shows Extra Docs accept copy with the SH-116 media contract", () => {
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
);
expect(
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"),
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB",
),
).toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toContain(".pdf");

View file

@ -0,0 +1,114 @@
import { fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const MB = 1_000_000;
function phoneFile(name: string, type: string, size: number): File {
return { name, type, size } as File;
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("SlideOverExtrasTab SH-116 media contract", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(probeVideoDurationSeconds).mockReset();
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
});
it("rejects a video longer than 90 seconds before upload", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
const onUpload = vi.fn();
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
);
selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects the 11th photo and 4th video counted across the whole work order", async () => {
const onUpload = vi.fn();
const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB);
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
mediaCounts={{ photos: 10, videos: 3 }}
isReadOnly={false}
onUpload={onUpload}
onRemove={vi.fn()}
/>,
);
selectFiles([
phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB),
report,
]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload).toHaveBeenCalledWith([report]);
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
});
it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const onUpload = vi.fn();
const video = phoneFile("IMG_0703.MOV", "", 60 * MB);
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
);
selectFiles([video]);
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
expect(toast.error).not.toHaveBeenCalled();
});
it("ignores a second pick while the first one is still being checked", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const onUpload = vi.fn();
renderWithProviders(
<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();
});
});

View file

@ -0,0 +1,172 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLocation } from "react-router";
import { describe, expect, it } from "vitest";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
import {
buildWorkOrderIdsHref,
WORK_ORDER_IDS_MAX,
} from "@/domain/work-orders/utils/work-order-ids-filter";
import { renderWithProviders } from "@/test/test-utils";
/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */
function BoardFilterHarness() {
const filters = useWorkOrdersListFilters();
useDashboardDrilldownFilters(filters.applyAdvancedFilters);
const location = useLocation();
const params = buildResolvedBoardSearchParams({
advApplied: filters.advApplied,
search: "",
page: 0,
pageSize: 200,
sortBy: "",
sortDir: "",
locations: [],
vendors: [],
});
return (
<>
<output aria-label="Board search params">
{params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))}
</output>
<output aria-label="Current search">{location.search}</output>
<button
type="button"
onClick={() => filters.applyAdvancedFilters({ ...filters.advInitial, rescheduled: true })}
>
Apply rescheduled only
</button>
<button type="button" onClick={() => filters.applyAdvancedFilters(filters.advInitial)}>
Apply unchanged
</button>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={2}
sites={[]}
users={[]}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
</>
);
}
function searchParams(): Record<string, unknown> | "weekly board" {
const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? "";
return text === "weekly board" ? text : (JSON.parse(text) as Record<string, unknown>);
}
describe("Work Orders exact id filter", () => {
it("opens the board on exactly the linked work orders and shows the selection chip", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9,4",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBe("4,9");
// No saved or default facet travels with the id set.
expect(params.statuses).toBeUndefined();
expect(params.dispatchers).toBeUndefined();
expect(params.types).toBeUndefined();
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
// The applied link params are consumed, like the dashboard drill-downs.
expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/);
});
it("returns to the weekly board when the selection chip is cleared", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: buildWorkOrderIdsHref([12]),
withAuth: false,
});
expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove filter" }));
expect(searchParams()).toBe("weekly board");
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
});
it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9",
withAuth: false,
});
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Apply rescheduled only" }));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBeUndefined();
expect(params.rescheduled).toBe(true);
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
});
it("keeps the selection when the panel is applied without a filter", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9",
withAuth: false,
});
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Apply unchanged" }));
expect((searchParams() as Record<string, unknown>).ids).toBe("4,9");
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
});
it("never sends more ids than the server accepts, however the selection was built", () => {
const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1));
const params = buildResolvedBoardSearchParams({
advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids },
search: "",
page: 0,
pageSize: 200,
sortBy: "",
sortDir: "",
locations: [],
vendors: [],
});
expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX);
});
it("ignores other drill-down params sent alongside the id set", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBe("5");
expect(params.statuses).toBeUndefined();
expect(params.dispatchers).toBeUndefined();
});
it("keeps the regular drill-down filters when no ids are given", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?statuses=Scheduled",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBeUndefined();
expect(params.statuses).toEqual([expect.any(Number)]);
});
});

View file

@ -367,7 +367,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute(
"aria-pressed",
"true",
"false",
);
expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument();
expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument();

View file

@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
function request(overrides: Partial<VendorPortalUpliftRequest> = {}): VendorPortalUpliftRequest {
return {
id: 7,
status: "Pending",
requestedNTE: 1500,
currentNTE: 1000,
vendorReason: "Extra parts",
raisedByVendor: true,
...overrides,
};
}
function renderCard(req: VendorPortalUpliftRequest) {
return render(
<UpliftRequestCard
request={req}
locked={false}
canAct
uploadingPercent={undefined}
submitting={false}
errorMessage=""
onWithdraw={vi.fn()}
onRevise={vi.fn()}
onCancelRevise={vi.fn()}
/>,
);
}
describe("UpliftRequestCard actions", () => {
it("offers Withdraw on a pending request the vendor raised", () => {
renderCard(request({ status: "Pending" }));
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument();
});
it("offers Withdraw and Revise when changes are requested on a request the vendor raised", () => {
renderCard(request({ status: "ChangesRequested" }));
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /revise/i })).toBeInTheDocument();
});
it("shows a work-order increase as the new NTE total", () => {
renderCard(request({ raisedByVendor: false, requestedNTE: 90, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$690.00 (was $600.00)",
);
});
it("shows a vendor request's requested total unchanged", () => {
renderCard(request({ raisedByVendor: true, requestedNTE: 900, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$900.00 (was $600.00)",
);
});
it("treats a request without the raised-by flag as vendor-raised", () => {
renderCard(request({ raisedByVendor: undefined, requestedNTE: 900, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$900.00 (was $600.00)",
);
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
});
it.each(["Pending", "ChangesRequested"])(
"shows a %s request raised from the work order read-only",
(status) => {
renderCard(request({ status, raisedByVendor: false, requestedNTE: 90, currentNTE: 600 }));
expect(screen.getByText(status)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /withdraw request/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument();
},
);
});

View file

@ -1,6 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
function renderForm(overrides: Partial<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
return render(
@ -38,6 +41,70 @@ describe("UpliftRequestForm revise mode", () => {
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
});
it("refuses photo evidence when the work order already has 10 photos", async () => {
renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
fireEvent.change(screen.getByLabelText(/evidence/i), {
target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
});
expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
});
it("offers and accepts the same phone photo and video types as the upload allowlist", async () => {
renderForm();
const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)");
expect(input.getAttribute("accept")?.split(",")).toEqual(
expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]),
);
fireEvent.change(input, {
target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] },
});
expect(
await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
).toBeInTheDocument();
fireEvent.change(input, {
target: { files: [new File(["heic"], "site.heic", { type: "" })] },
});
await waitFor(() =>
expect(
screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
).not.toBeInTheDocument(),
);
});
it("holds submit while a picked video is still being checked, then submits that video", async () => {
let finishProbe: (seconds: number | null) => void = () => {};
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() => new Promise((resolve) => (finishProbe = resolve)),
);
const onSubmit = vi.fn();
renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit });
const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" });
fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } });
const checking = await screen.findByRole("button", { name: "Checking evidence…" });
expect(checking).toBeDisabled();
fireEvent.submit(checking.closest("form")!);
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument();
finishProbe(30);
const submitButton = await screen.findByRole("button", { name: /submit revision/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({
amount: 1200,
reason: "Extra parts",
evidenceFile: video,
});
});
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });

View file

@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => {
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, and PNG files are allowed.",
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
@ -62,6 +65,163 @@ describe("VendorPortalDocuments", () => {
expect(screen.getByText("Processing")).toBeInTheDocument();
});
it.each([
["animation.gif", "image/gif"],
["clip.webm", "video/webm"],
])("rejects %s (%s), which the server does not accept", async (name, type) => {
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [new File(["x"], name, { type })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
describe("SH-116 media contract", () => {
const MB = 1_000_000;
const renderDocuments = () =>
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const choose = (file: File) =>
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [file] },
});
it("states the limit for every accepted kind, including PDFs", () => {
renderDocuments();
expect(
screen.getByText(
"Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB",
),
).toBeInTheDocument();
});
it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderDocuments();
const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
choose(video);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0].file).toBe(video);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([
[
{ name: "clip.mp4", type: "video/mp4", size: 101 * MB },
"Videos must be 100 MB or smaller.",
],
[
{ name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
"Photos must be 10 MB or smaller.",
],
])("rejects %o with a generic message", async (file, message) => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderDocuments();
choose(file as File);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(upload).not.toHaveBeenCalled();
});
// 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 () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
@ -97,4 +257,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);
});
});

View file

@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
const apiPostNoContent = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
@ -131,6 +132,8 @@ describe("notification center", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(standardFeed);
apiPostNoContent.mockReset();
apiPostNoContent.mockResolvedValue(undefined);
});
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
@ -256,6 +259,58 @@ describe("notification center", () => {
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
});
it("keeps Clear all disabled when only rows that need acknowledging are left", async () => {
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled();
});
it("acknowledges a missed deadline through the API and removes the row", async () => {
apiGet.mockResolvedValue(
feed(
section("sla", "SLA at Risk", [acknowledgeRow]),
section("unassigned", "Unassigned", [unassigned]),
),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" });
expect(acknowledge).toHaveAttribute("title", "Acknowledge");
expect(acknowledge).toHaveTextContent("Acknowledge");
apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned])));
await user.click(acknowledge);
expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge");
await waitFor(() =>
expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(),
);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
});
it("keeps the row when the acknowledgement fails", async () => {
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
apiPostNoContent.mockRejectedValue(new Error("boom"));
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" }));
await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(),
);
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
});
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
const user = userEvent.setup();
renderBell();

View file

@ -0,0 +1,148 @@
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLocation } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
import { queryKeys } from "@/infra/query-key/query-key";
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
const toastWarn = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } }));
const BANNER = "Work orders at risk of missing their response deadline";
function atRisk(id: number, number: string | null = String(1000 + id)) {
return {
id,
number,
severity: 1,
startedAt: "2026-09-18T10:00:00Z",
deadlineAt: "2026-09-18T12:00:00Z",
percentElapsed: 60,
};
}
function feed(...slaAtRisk: ReturnType<typeof atRisk>[]) {
return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk };
}
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBanner() {
return renderWithProviders(
<NotificationSessionProvider>
<SlaAtRiskBanner />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false },
);
}
describe("SLA at-risk banner", () => {
beforeEach(() => {
apiGet.mockReset();
toastWarn.mockReset();
});
it("stays hidden while no work order is past half of its response window", async () => {
apiGet.mockResolvedValue(feed());
renderBanner();
await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications"));
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
expect(toastWarn).not.toHaveBeenCalled();
});
it("names the single work order at risk", async () => {
apiGet.mockResolvedValue(feed(atRisk(101)));
renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
expect(
within(banner).getByRole("button", {
name: "WO #1101 is at risk of missing its response deadline",
}),
).toBeInTheDocument();
});
it("collapses several work orders to one line and opens Work Orders on exactly those", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
const user = userEvent.setup();
renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
const open = within(banner).getByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
});
expect(open).toHaveAttribute("title", "View in Work Orders");
expect(within(banner).getAllByRole("button")).toHaveLength(2);
await user.click(open);
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?ids=101,205,318",
);
});
it("dismisses for this session only and returns when a new work order becomes at risk", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205)));
const user = userEvent.setup();
const { queryClient, unmount } = renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" }));
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
const returned = await screen.findByRole("region", { name: BANNER });
expect(
within(returned).getByRole("button", {
name: "WO #1318 is at risk of missing its response deadline",
}),
).toBeInTheDocument();
unmount();
renderBanner();
expect(
await screen.findByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
}),
).toBeInTheDocument();
});
it("toasts each work order once per session, however often the feed polls", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null)));
const { queryClient } = renderBanner();
await screen.findByRole("region", { name: BANNER });
expect(toastWarn).toHaveBeenCalledTimes(2);
expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", {
toastId: "sla-at-risk-101",
});
expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", {
toastId: "sla-at-risk-205",
});
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318)));
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
await screen.findByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
});
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
expect(toastWarn).toHaveBeenCalledTimes(3);
expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", {
toastId: "sla-at-risk-318",
});
});
});

View file

@ -27,6 +27,7 @@ describe("dashboardApi", () => {
ScheduledTomorrow: "3",
PendingUplifts: 2,
AvetaPending: "1",
Unassigned: "6",
Breakdown: [
{ Status: "Scheduled", Count: "7" },
{ Status: "Completed", Count: 4 },
@ -52,6 +53,7 @@ describe("dashboardApi", () => {
scheduledTomorrow: 3,
pendingUplifts: 2,
avetaPending: 1,
unassigned: 6,
breakdown: [
{ status: "Scheduled", count: 7 },
{ status: "Completed", count: 4 },

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type {
NotificationFeed,
NotificationItem,
} from "@/domain/notifications/types/notification-feed";
import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
function row(
overrides: Partial<NotificationItem> & Pick<NotificationItem, "id">,
): NotificationItem {
return {
reason: "sla",
severity: "Critical",
rowType: "acknowledge",
title: "missed its response deadline",
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
workOrders: [],
...overrides,
};
}
const breach9 = row({ id: "sla-breach-9" });
const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } });
const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" });
const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" });
const feed: NotificationFeed = {
generatedAt: "2026-09-18T12:00:00Z",
slaAtRisk: [],
sections: [
{
reason: "sla",
label: "SLA at Risk",
severity: "Critical",
count: 3,
items: [breach9, breach10, atRisk9],
},
{
reason: "aveta-missing",
label: "Aveta Missing",
severity: "Medium",
count: 1,
items: [aveta9],
},
],
};
describe("withoutSlaBreach", () => {
it("drops only the acknowledged work order's breach row and lowers its section count", () => {
const next = withoutSlaBreach(feed, 9);
expect(next.sections[0].items.map((item) => item.id)).toEqual([
"sla-breach-10",
"sla-at-risk-9",
]);
expect(next.sections[0].count).toBe(2);
expect(next.sections[1]).toBe(feed.sections[1]);
});
it("leaves the feed as it was when that work order has no breach row", () => {
const next = withoutSlaBreach(feed, 42);
expect(next.sections).toEqual(feed.sections);
expect(next.sections[0]).toBe(feed.sections[0]);
});
});

View file

@ -420,6 +420,14 @@ describe("workOrdersApi.searchBoard", () => {
});
});
/** A WO# lookup spans every week and undated rows. */
const ALL_WEEKS_SEARCH = {
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: "true",
};
describe("workOrdersApi.findByWoNumber", () => {
beforeEach(() => {
apiGet.mockReset();
@ -450,6 +458,7 @@ describe("workOrdersApi.findByWoNumber", () => {
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "10000001235",
...ALL_WEEKS_SEARCH,
page: "0",
pageSize: "100",
});
@ -519,6 +528,7 @@ describe("workOrdersApi.findByWoNumber", () => {
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "9",
...ALL_WEEKS_SEARCH,
page: "0",
pageSize: "100",
});

View file

@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links";
import {
avetaPendingDrilldownSearch,
unassignedDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
describe("parseDashboardDrilldownParams", () => {
it("returns null when no recognized param is present", () => {
@ -60,6 +67,41 @@ describe("parseDashboardDrilldownParams", () => {
expect(filters?.regions).toEqual(new Set(["California"]));
});
it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => {
const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time"));
expect(allTime).not.toBeNull();
expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
});
const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
const weekParams = toBoardSearchQueryParams({ filters: week! });
expect(week?.dateRange).toBe("custom");
expect(weekParams).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
});
expect(weekParams.includeDateless).toBeUndefined();
});
it("opens the Unassigned tile link as the Unassigned option of Assigned to", () => {
const filters = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
expect(filters?.dispatchers).toEqual(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
const chips = buildAdvancedFilterChips(filters!, { dispatcherNameOf: (id) => id });
const assignedTo = chips.filter((chip) => chip.group === "Assigned to");
expect(assignedTo.map((chip) => chip.value)).toEqual(["Unassigned"]);
const cleared = assignedTo[0]!.clear(filters!);
expect(cleared.dispatchers.size).toBe(0);
});
it("rejects partial or malformed date ranges", () => {
expect(
parseDashboardDrilldownParams(

View file

@ -40,12 +40,12 @@ describe("countAdvancedFilters", () => {
expect(countAdvancedFilters(filters)).toBe(3);
});
it("counts Unscheduled +1 with the this-week default still in place", () => {
it("counts Unscheduled +1 with no date range selected", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
unscheduledOnly: true,
};
expect(filters.dateRange).toBe("this-week");
expect(filters.dateRange).toBeNull();
expect(countAdvancedFilters(filters)).toBe(1);
});

View file

@ -130,9 +130,43 @@ describe("toBoardSearchQueryParams", () => {
customTo: "2026-06-30",
},
});
expect(thisWeek.datePreset).toBe("Custom");
expect(thisWeek.dateFrom).toBe("2000-01-01");
expect(thisWeek.dateTo).toBe("2099-12-31");
expect(thisWeek.datePreset).toBe("ThisWeek");
expect(thisWeek.dateFrom).toBeUndefined();
expect(thisWeek.dateTo).toBeUndefined();
});
it("narrows strictly for every selected range, This week included", () => {
for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
dateRange,
customFrom: "2026-09-21",
customTo: "2026-09-25",
dispatchers: new Set(["__unassigned__"]),
},
});
expect(params.includeDateless).toBeUndefined();
expect(params.dateFrom).not.toBe("2000-01-01");
}
});
it("searches every week, undated rows included, when no range is selected", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) },
});
expect(EMPTY_ADVANCED_FILTERS.dateRange).toBeNull();
expect(params).toMatchObject({
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
dispatchers: ["__unassigned__"],
});
});
it("keeps undated rows in the pinned Unassigned queue", () => {
expect(toUnassignedQueueQueryParams().includeDateless).toBe(true);
});
it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {

View file

@ -454,6 +454,56 @@ describe("matchesAdvancedFilters", () => {
expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true);
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
});
it("matches a range the same way the board search does", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "2026-09-23",
customTo: "2026-09-25",
};
// Week-only: a target week overlapping the range matches, whatever date the
// row still carries.
const weekOnlyStaleDate = makeRow({
scheduledOn: "2026-10-02",
targetWeek: "2026-09-21",
scheduleWeekOnly: true,
});
const weekOnlyPreviousWeek = makeRow({
scheduledOn: "",
targetWeek: "2026-09-14",
scheduleWeekOnly: true,
});
const weekOnlyOutside = makeRow({
scheduledOn: "",
targetWeek: "2026-09-28",
scheduleWeekOnly: true,
});
const datedOutsideWithWeek = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21" });
const undated = makeRow({ scheduledOn: "", targetWeek: "" });
expect(matchesAdvancedFilters(weekOnlyStaleDate, filters)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyPreviousWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false);
expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(undated, filters)).toBe(false);
// No range: every week, plus open undated rows. Closed undated rows are
// never unscheduled, with or without the Unscheduled filter.
const noRange = { ...filters, dateRange: null };
const unscheduledOnly = { ...filters, unscheduledOnly: true };
const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" });
const canceledUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Canceled" });
expect(matchesAdvancedFilters(undated, noRange)).toBe(true);
expect(matchesAdvancedFilters(datedOutsideWithWeek, noRange)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyOutside, noRange)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(undated, unscheduledOnly)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, unscheduledOnly)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, unscheduledOnly)).toBe(false);
});
});
describe("computeIsPastDue", () => {

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
WORK_ORDER_IDS_MAX,
buildWorkOrderIdsHref,
normalizeWorkOrderIds,
parseWorkOrderIdsParam,
} from "@/domain/work-orders/utils/work-order-ids-filter";
describe("work-order ids filter", () => {
it("builds the Work Orders link for an exact id set", () => {
expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318");
});
it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => {
expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101");
expect(buildWorkOrderIdsHref([])).toBe("/workorders");
});
it("reads the ids param, ignoring malformed tokens", () => {
expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]);
expect(parseWorkOrderIdsParam(null)).toEqual([]);
expect(parseWorkOrderIdsParam("")).toEqual([]);
});
it("keeps at most the backend limit of distinct ids", () => {
const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1);
const normalized = normalizeWorkOrderIds([...ids, 1, 2]);
expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX);
expect(normalized[0]).toBe(1);
expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX);
});
});

View file

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

View file

@ -1,14 +1,20 @@
import { describe, expect, it } from "vitest";
import {
canDeleteWorkOrderMedia,
countWorkOrderMediaKinds,
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
MAX_WORK_ORDER_MEDIA_BYTES,
MAX_WORK_ORDER_PHOTO_BYTES,
MAX_WORK_ORDER_VIDEO_BYTES,
workOrderExtraDocsAcceptAttribute,
workOrderFileSizeRejection,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
@ -25,12 +31,53 @@ describe("work-order-media-limits", () => {
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
});
it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
expect(formatMediaSizeLimit()).toBe("200 MB");
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
it("applies the SH-116 per-kind caps with stable generic messages", () => {
const file = (name: string, type: string, size: number) => ({ name, type, size }) as File;
expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000);
expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000);
expect(MAX_VIDEO_DURATION_SECONDS).toBe(90);
expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10);
expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3);
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull();
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull();
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull();
expect(
workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)),
).toBeNull();
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe(
"Videos must be 100 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
// A foreign declared type never widens the class: this .jpg is still sized as a photo.
expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe(
"Documents must be 50 MB or smaller.",
);
expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe(
false,
);
expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB");
});
it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => {
expect(
countWorkOrderMediaKinds([
{ fileType: "image" },
{ fileType: "image", isLegacy: true },
{ fileType: "video" },
{ fileType: "document" },
{ fileType: "video", uploadStatus: "failed" },
]),
).toEqual({ photos: 1, videos: 1 });
});
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {

View 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();
});
});