Compare commits

...

68 commits

Author SHA1 Message Date
npalseahaven
502808ff7d
Merge 999f1f03fa into e51551c160 2026-09-19 17:36:45 -03:00
Adam Moussa
e51551c160
Merge pull request #239 from Sea-Haven-Industries/chore/parallel-ci-gates
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
ci: fan out quality gates from this repository
2026-09-19 20:32:50 +00:00
Cursor Agent
cc7ecb9b5a
fix(ci): classify G13 per queued PR on merge_group
Run live isolation on the merge-queue event so ci-complete actually
gates mixed change sets, without treating the group union as one PR.
2026-09-19 20:20:30 +00:00
Adam Moussa
ec9812ae8d fix(style): correct line-wrapping formatting errors 2026-09-19 19:47:08 +00:00
af0ae60b80 ci: shard unit tests four ways 2026-09-19 15:41:10 -04:00
8ad7438f26 ci: fan out quality gates from this repository 2026-09-19 15:22:38 -04:00
Alexandre Brandizzi
3e5fba4d2b
Merge pull request #225 from Sea-Haven-Industries/feat/ab/sh-292-notification-center
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-292: Notification Center panel and feed
2026-09-18 23:33:59 +00:00
Alexandre Brandizzi
cbfed3dde8
Merge pull request #226 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-modal
SH-209: Align the Uplift Detail modal with the spec
2026-09-18 23:33:55 +00:00
Alexandre Brandizzi
f25a3dd628
Merge pull request #228 from Sea-Haven-Industries/fix/ab/sh-379-poc-persist
Persist manual POC edits via dedicated POC endpoint (SH-379)
2026-09-18 23:33:52 +00:00
Adam Moussa
41b26692c3
Merge pull request #238 from Sea-Haven-Industries/chore/pr-template-and-cleanup
chore(repo): add PR template, fix README conventions, drop tracked scratch files
2026-09-18 23:30:58 +00:00
1698d53d38
chore(repo): add PR template, fix README conventions, drop tracked scratch files
The org PR template pre-filled Summary / Validation / Tests / Notes here while
PRs in this repository use Summary / Changes and value / Ticket. A repo template
now overrides the org one, and the review framework gains the description
contract plus a note on the divergence from the org pr-policy workflow, which
is not wired in.

README: the CI badge tracked the retired dev branch; branches come from main,
not dev; the fix/ prefix replaces bug/; staging exists alongside dev; and PRs
now merge through the merge queue.

Cleanup: four PR description drafts under tmp/ were tracked; they are removed
and /tmp/ is ignored.
2026-09-18 19:19:55 -04:00
Adam Moussa
73e3362fcf
Merge pull request #237 from Sea-Haven-Industries/renovate/reconfigure
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
2026-09-18 23:09:28 +00:00
Adam Moussa
e611f193bd
ci: run required checks on merge_group for the merge queue (#236)
governance and Build and test are the required checks on main. A merge queue
only counts checks that ran on the merge_group event, so the workflow now
triggers on it. The governance gate reads the merge group's own base SHA
because github.event.before is empty there.
2026-09-18 19:03:12 -04:00
bebd517290
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
The org window (before 6am on Monday) has produced only one security PR in
this repository since the overlay landed, so the overlay now opens every
weekday morning. The github-actions manager is enabled so the digest-pinned
actions in the workflows follow the org's pinning and grouping rules. A regex
manager tracks the actionlint release installed by the governance job; it is
held for dashboard approval because the SHA256 pin next to it has to be
updated by hand.
2026-09-18 18:50:49 -04:00
Alexandre Brandizzi
7c470d389c
Merge pull request #231 from Sea-Haven-Industries/fix/ab/sh-380-completion-pdf-post-upload-error
fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
2026-09-18 19:47:47 -03:00
Alexandre Brandizzi
de7066d31b
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:33:59 -03:00
Alexandre Brandizzi
2c47b4a9ca
Merge pull request #212 from Sea-Haven-Industries/feat/ab/sh-304-services-registry-ui
SH-304: Add Services registry management UI
2026-09-18 19:33:33 -03:00
Alexandre Brandizzi
7fdf85fc47
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:21:21 -03:00
Alexandre Brandizzi
7eb7e4a730
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:21:18 -03:00
Alexandre Brandizzi
9bba6402e0
Merge pull request #230 from Sea-Haven-Industries/feat/ab/sh-382-extra-doc-view
fix(work-orders): view extra documents (SH-382)
2026-09-18 19:20:59 -03:00
Alexandre Brandizzi
7c1243eec3
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:09:49 -03:00
Alexandre Brandizzi
744d7af7bb
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 19:09:47 -03:00
Alexandre Brandizzi
e3afa182d6
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:09:44 -03:00
Alexandre Brandizzi
d7ee290b1d
Merge pull request #202 from Sea-Haven-Industries/fix/ab/sh-364-inline-technician-registration
Some checks failed
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Terraform CI / terraform (push) Has been cancelled
SH-364, SH-365, SH-366: register inline technicians from work-order flows
2026-09-18 19:09:19 -03:00
Alexandre Brandizzi
4d3dd82afb
Merge branch 'main' into fix/ab/sh-364-inline-technician-registration 2026-09-18 18:58:01 -03:00
Alexandre Brandizzi
29caecd449
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 18:57:58 -03:00
Alexandre Brandizzi
e1b03dc151
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 18:57:56 -03:00
Alexandre Brandizzi
5766616cc0
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 18:57:53 -03:00
Alexandre Brandizzi
8d50c6f75e fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
The completion-doc upload endpoint sets DocStatus=Yes and bumps the work-order row
version in one transaction. Both signed-PDF upload paths then re-ran a "docStatus=Yes"
work-order patch using the pre-upload row version (the comp-doc dialog re-ran the whole
Generate flow; the slide-over posted docStatus directly). That patch always 409'd — and
in the dialog it also demanded a Date Work Completed — so a successful upload surfaced an
error to the user.

Both paths now stop after the upload settles: the mutation's onSuccess refreshes the
board and the UI reflects the generated state locally, with no second docStatus patch.
2026-09-18 16:59:24 -03:00
Alexandre Brandizzi
1a7a4ba58f fix(work-orders): open extra documents without credentialed CORS (SH-382)
Viewing a work-order extra document called the authorized content endpoint
with `credentials: "include"`. The API replies `Access-Control-Allow-Origin: *`,
and in credentialed mode the browser rejects a wildcard origin outright, so the
fetch threw, `openExtraDocContent` fell into its catch, closed the tab and
toasted "Unable to open this document." — QA CT-03.

The endpoint authenticates with the bearer token the ky beforeRequest hook
attaches, not cookies, so credentials mode was dead weight. List, upload and
delete never set it, which is why only viewing failed. Drop the option and
guard the request shape in a test (JSDOM cannot enforce CORS).
2026-09-18 16:35:01 -03:00
Codex Review Integration
84568dd737 refactor(workorders): extract board patch api module
work-orders-api.ts exceeded the 500-line godfile cap after adding
updatePoc. Move patchBoardField/updatePoc and their shared response
handling into work-order-board-patch-api.ts and re-export through
workOrdersApi, mirroring workOrderBoardDocumentsApi.
2026-09-18 15:03:59 -03:00
Codex Review Integration
3b1da21abb fix(workorders): preserve partial POC edits 2026-09-18 15:00:01 -03:00
Codex Review Integration
5b0c758a9f fix(workorders): persist manual POC edits via dedicated POC endpoint (SH-379)
The UI dropped manual POC edits before they reached the API: the patch
mapper listed pocName/pocPhone/pocNotes as local-only keys and the
slide-over draft excluded them from Save, so the optimistic edit vanished
on refetch and the completion freeze captured the Site contact instead.

- Emit one composite POC op from table patches and route it through a new
  workOrdersApi.updatePoc (PATCH workorders/{id}/poc), reusing the board
  patch row/error contract (409 conflict with currentState, 422 validation).
- Include POC scalars in slide-over edit keys so dirty state and Save carry
  them; site dialog and slide-over now both persist POC edits.
2026-09-18 14:44:06 -03:00
Codex Review Integration
8b122ad71c fix: satisfy governance hook line limits 2026-09-18 14:38:27 -03:00
Codex Review Integration
062a1e26c8 fix: resolve notification review findings 2026-09-18 14:23:43 -03:00
Codex Review Integration
edd2e7efe2 test(visual): refresh mobile navigation baseline for the notification bell 2026-09-18 13:52:00 -03:00
Codex Review Integration
988c96c42d fix(notifications): keep vendor reminder actions and refresh header baselines
No Vendor opens the work order form on vendor assignment and Vendor Conflict
links each work order, as the vendor reminders did. Header baselines include the
notification bell, and the top bar user menu moves to its own component to stay
within the function-size gate. The vendor e2e now covers the feed contract.
2026-09-18 13:34:12 -03:00
Codex Review Integration
33d0ab4fd6 test(uplifts): scope modal permission and date assertions to what they prove
The permission spec asserts the work order renders from the queue item
and, separately, that only the approved-on-WO breakdown degrades to
Unavailable. The Requested At assertion derives the local calendar day
so it holds in every runner time zone.
2026-09-18 13:02:11 -03:00
Codex Review Integration
dcf944d7dc SH-292: Notification Center bell panel and feed page
Header bell panel and /notifications page share one feed from GET /api/notifications: labeled sections with live counts, unread styling, session-only dismiss and clear all (acknowledge rows excluded), and deep links to the unassigned queue and a work order's tab via ?wo=&tab=.
2026-09-18 12:55:54 -03:00
Codex Review Integration
3c84d851c0 feat(uplifts): align uplift detail modal with SH-209
Two sections, Work order and Uplift request, divided by a single rule.
Work order shows Service, Vendor / Technician, Assigned Dispatcher and
Scheduled from the queue item, so it no longer fetches the work order
and no longer degrades to Unavailable for account-scoped staff. Uplift
request shows Requested By, Requested At, the justification in a
bordered block, the approved-on-WO breakdown and a horizontal
attachment row whose chips open the evidence in a new tab. Every field
renders data or an explicit placeholder. The footer shows Reject and
Approve for pending uplifts, Revoke for approved ones and nothing for
read-only records; closing is the header X.
2026-09-18 12:53:55 -03:00
Alexandre Brandizzi
c9562650ca
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-18 11:59:16 -03:00
Alexandre Brandizzi
e780bafdd8
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-18 11:59:13 -03:00
Codex Review Integration
28ff14d457 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/test/lib/auth/user-utils.test.ts
2026-09-17 15:34:52 -03:00
Codex Review Integration
8a0a134238 style: format wizard-vendor-company-fields with prettier 2026-09-17 15:31:15 -03:00
Codex Review Integration
abe28a3a7f Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
#	src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx
#	src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
#	src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
2026-09-17 15:27:39 -03:00
Alexandre Brandizzi
9ed8099e71
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:16:25 -03:00
Alexandre Brandizzi
361f6b65a2
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:10:25 -03:00
Alexandre Brandizzi
534d7ce4d6
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 15:02:19 -03:00
Codex Review Integration
bbdf418ee3 refactor(services): extract useServiceEditor to satisfy maintainability gate
useServicesRegistryController exceeded the changed-file max-lines-per-function
budget (158 > 150). Move the add/edit form lifecycle into a useServiceEditor
sub-hook; the controller's public return shape and behavior are unchanged.
2026-09-17 14:55:05 -03:00
Alexandre Brandizzi
65d55d7c9f
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:48:26 -03:00
Alexandre Brandizzi
ff4ecb37e4
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:48:16 -03:00
Alexandre Brandizzi
0eeae02785
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:34:43 -03:00
Alexandre Brandizzi
0302b2828f
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:34:33 -03:00
Alexandre Brandizzi
3dbf3df5d5
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:26:40 -03:00
Alexandre Brandizzi
3011cb1eda
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:26:30 -03:00
Codex Review Integration
90a3c24846 fix(services): source Trade picker from canonical catalog (SH-304)
The Trade select was fed from the editable DropdownOptions "Trade"
category, whose legacy seed values ("Plumbing", "General", ...) are not
in the backend TradeCatalog. ServicesRegistryService.RequireCanonicalTrade
rejects any non-canonical trade with 400 TradeInvalid, so Add Service and
Save Changes failed for legacy names and an admin editing the Trade
dropdown could break service creation at any time.

Source the picker from the server-owned canonical Trade reference on
Vendor/facets (the same list the vendor roster form uses), key the icon
defaults to the canonical names, and keep the edited service's stored
trade selectable if it was later deactivated so editing never silently
blanks the field.
2026-09-17 14:14:48 -03:00
Codex Review Integration
c40f0c4a37 Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
2026-09-17 13:26:17 -03:00
Codex Review Integration
7dd7ea497e Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/api/api-paths.ts
#	src/domain/services/api/services-api.ts
#	src/domain/services/mappers/service-mapper.ts
#	src/domain/services/types/service.ts
#	src/infra/query-key/query-key.ts
#	src/test/domain/services/api/services-api.test.ts
2026-09-17 13:24:08 -03:00
Codex Review Integration
f39328576e fix(services): replace status filter with active/inactive tabs (SH-304) 2026-09-17 02:01:08 -03:00
Codex Review Integration
2fb5d4a965 fix(services): satisfy registry maintainability gate 2026-09-16 22:50:09 -03:00
Codex Review Integration
9c360fc95b fix(services): address registry review findings 2026-09-16 22:30:32 -03:00
Codex Review Integration
0b229fd054 fix(work-orders): validate inline technician phone before roster save
The inline technician rows on the wizard's Vendor & time step had no
client-side phone check, so a number that does not normalize to ten digits
failed the roster PATCH and blocked the whole Create with the raw backend
message in a toast. Validate each staged phone with the same
isValidNorthAmericanPhone predicate the vendor roster schema uses, so the
inline path matches the modal path. A blank phone stays valid.
2026-09-16 19:45:07 -03:00
Codex Review Integration
1c66f8d60e fix(services): cache completion template options 2026-09-16 19:13:28 -03:00
Codex Review Integration
b449df97e6 fix(services): use completion document templates 2026-09-16 19:13:00 -03:00
Codex Review Integration
0b63d882ab refactor(services): split registry management view 2026-09-16 19:05:02 -03:00
Codex Review Integration
c9ab1f3598 feat(services): add registry management page 2026-09-16 18:42:31 -03:00
Codex Review Integration
712d8e0dc6 fix(work-orders): clear stale technician drafts on selection changes 2026-09-16 16:50:26 -03:00
Codex Review Integration
ee95847354 fix(work-orders): register inline technicians through the additive vendor roster 2026-09-16 13:16:44 -03:00
130 changed files with 6032 additions and 1309 deletions

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

21
.github/renovate.json vendored
View file

@ -1,6 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["npm", "custom.regex", "terraform"],
"enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"],
"schedule": ["before 6am every weekday"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
@ -14,6 +15,17 @@
"datasourceTemplate": "npm",
"depNameTemplate": "@playwright/test",
"versioningTemplate": "npm"
},
{
"customType": "regex",
"description": [
"actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard"
],
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
"matchStrings": ["ACTIONLINT_VERSION: \"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
"datasourceTemplate": "github-releases",
"depNameTemplate": "rhysd/actionlint",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
"packageRules": [
@ -36,6 +48,13 @@
"dependencyDashboardApproval": true,
"groupName": "playwright"
},
{
"description": [
"Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version"
],
"matchPackageNames": ["rhysd/actionlint"],
"dependencyDashboardApproval": true
},
{
"description": ["Keep MUI packages together"],
"matchPackageNames": ["@mui/**"],

View file

@ -1,56 +0,0 @@
name: Terraform CI
# Static checks only. Plans run in HCP Terraform as speculative VCS runs on
# the PR (shoc-frontend-new-dev and shoc-frontend-new-staging). Applies are
# HCP auto-apply on merge to main (dev) and on a vX.Y.Z-staging tag (staging).
on:
pull_request:
branches: [main, dev]
paths:
- "terraform/**"
- "scripts/**"
- ".github/workflows/ci-terraform.yaml"
- ".github/workflows/deploy-web.yaml"
push:
branches: [main]
paths:
- "terraform/**"
- "scripts/**"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive terraform
- name: Validate live/dev
run: |
terraform -chdir=terraform/live/dev init -backend=false -input=false -lockfile=readonly -no-color
terraform -chdir=terraform/live/dev validate -no-color
- name: Validate live/staging
run: |
terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color
terraform -chdir=terraform/live/staging validate -no-color
- name: Import plan guard tests
run: python3 scripts/test-terraform-import-plan-check.py
- name: App/Terraform isolation tests
run: python3 scripts/test_check_app_terraform_isolation.py

View file

@ -3,6 +3,9 @@ name: Frontend checks
on:
pull_request:
branches: [main, dev]
# The merge queue builds main plus the queued pull requests on a temporary
# branch and only counts checks that ran on the merge_group event.
merge_group:
push:
branches: [main]
workflow_dispatch: {}
@ -10,28 +13,86 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-and-test:
name: Build and test
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
static:
name: static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run build
unit:
name: unit
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
visual:
name: Visual regression
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
governance:
# Repo-owned guarantee that every frontend quality gate runs from this
# repository, independent of (and in addition to) the reusable workflow.
# `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan
# guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13
# app/Terraform isolation). Runs on PRs to main or dev; push is main only.
# If the reusable workflow is later confirmed to run every gate, this job
# can be slimmed to `npm run governance`.
# Repo-owned gates: godfile ratchet, changed-file maintainability,
# Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront
# verify, GitHub workflow shell, isolation classifier tests, and live G13
# (pull_request, merge_group per queued PR, and local). Format, lint, build,
# and unit tests run
# in the parallel jobs above, not here.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
# merge group -> the group's own base (github.event.merge_group.base_sha)
# push-> the previous commit on the branch (github.event.before)
# manual -> main, for exact-head recovery runs
runs-on: ubuntu-latest
@ -46,10 +107,13 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
base="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then
base="${MERGE_GROUP_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
@ -79,29 +143,29 @@ jobs:
tar -xzf actionlint.tar.gz actionlint
sudo mv actionlint /usr/local/bin/actionlint
- run: npm ci
- run: npm run verify
- run: npm run governance
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
visual-regression:
name: Visual regression
ci-complete:
name: ci-complete
if: always()
needs: [static, build, unit, visual, governance]
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
- name: All required jobs passed
shell: bash
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
failed=0
for result in ${RESULTS}; do
if [[ "${result}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo "Required jobs did not all succeed: ${RESULTS}"
exit 1
fi

View file

@ -28,7 +28,6 @@ on:
- "docs/**"
- "**/*.md"
- ".github/workflows/ci.yaml"
- ".github/workflows/ci-terraform.yaml"
- ".github/workflows/deploy-web.yaml"
release:
types: [published]

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ seed-data.sql
# python
__pycache__/
*.py[cod]
# local scratch
/tmp/

View file

@ -30,8 +30,9 @@ This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScrip
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
claim a task is done until `npm run verify` is green locally.** CI runs the same
`npm run verify` in a repo-owned `governance` job, so a green local run mirrors
CI.
gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)
(`static`, `build`, `unit`, `visual`, `governance`) with `ci-complete` failing if
any of those jobs did not succeed.
## Non-negotiable rules (enforced; do not work around)

View file

@ -14,24 +14,24 @@ isolation). A task is not done until this is green.
## Gate matrix
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` |
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
## No-false-pass guarantees
@ -60,15 +60,17 @@ isolation). A task is not done until this is green.
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
Prettier on staged files at commit; commitlint enforces Conventional Commits.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
format/lint/build/tests, **and** a repo-owned `governance` job runs
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow.
- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):**
fmt, `init -backend=false`, validate, import-plan unit tests, and G13
classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
repository owns every job. `static` (`format:check` + `lint`), `build`
(`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
(Playwright visual), and `governance` (`npm run governance`, with Terraform
1.16.0) run in parallel. `ci-complete` fails unless all of those jobs
succeeded and is the required merge-queue check. Live G13 runs on
`pull_request` (merge-base range), `merge_group` (each queued PR as a
first-parent commit), and locally. It skips `push`. A Terraform-only PR
stacked with an app-only PR still passes; a mixed change set still fails.
Terraform fmt/validate and the related unit tests run inside `governance` on
every event.
## Toolchain pin

View file

@ -1,6 +1,6 @@
# SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
@ -13,7 +13,8 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
@ -122,19 +123,23 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
- Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time.
- Open PRs against `main`. Protected branches need a green CI run and an
approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
docs, and gate-script changes may travel with either side. `deploy-web.yaml`
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
- Open PRs against `main`. The PR body uses the three-section layout the
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
`ci-complete` check and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
approvals. PRs merge through the merge queue, so a branch does not need to
be updated with `main` before it merges. Merged branches are deleted
automatically.
- A change set cannot mix `terraform/` with deployable application files (G13),
including each queued PR on the merge-group check. Workflow, docs, and
gate-script changes may travel with either side. `deploy-web.yaml` still
ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
prod distribution exists.
@ -144,19 +149,12 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)
and the Terraform gates) is guaranteed from this repository. Conventions
and gates are documented under
and PRs to `main`, runs format, lint, build, sharded unit tests, visual
regression, and `npm run governance` as parallel jobs, then `ci-complete`.
Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **Terraform CI**
([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml))
— fmt, `init -backend=false`, validate, and import-plan tests.
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.

View file

@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`,
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either
side.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.
- **Changes and value** — grouped by area, each with the value it delivers.
- **Ticket** — the board key(s) when one applies; "None" otherwise.
- Link any ADR relied upon.
Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View file

@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
const workOrder = { kind: "workOrder", tab: "info" };
await page.route("**/api/notifications", (route) =>
fulfillJson(route, {
items: [
generatedAt: "2026-07-23T12:00:00Z",
sections: [
{
id: "no-vendor-501",
type: "NoVendor",
reason: "no-vendor",
label: "No Vendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
count: 1,
items: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
id: "no-vendor-501",
reason: "no-vendor",
severity: "Medium",
rowType: "dismissable",
title: "WO #WO-501 starts within 48h with no vendor assigned",
count: 1,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [{ id: 501, number: "WO-501" }],
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
},
{
reason: "vendor-conflict",
label: "Vendor Conflict",
severity: "Medium",
count: 2,
items: [
{
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
rowType: "dismissable",
title: "Acme has overlapping appointments",
count: 2,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [
{ id: 501, number: "WO-501" },
{ id: 502, number: "WO-502" },
],
},
],
},
],
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
});
test("filters insights and exposes both export formats", async ({ page }) => {

View file

@ -0,0 +1,265 @@
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [BOARD_ROW],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});

View file

@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await page.getByRole("button", { name: /Select technician/ }).click();
await page
.getByRole("button", { name: /Adam Whyte/ })

View file

@ -17,7 +17,7 @@
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",

View file

@ -0,0 +1,29 @@
/**
* Live G13 scheduling and how a GitHub event is split into change sets.
*
* Isolation is a per-change rule. pull_request and local runs classify the
* merge-base...HEAD range (one PR). merge_group classifies each first-parent
* commit vs its parent (one queued PR per squash or merge-commit). The group
* union is not a change set: a Terraform-only PR stacked with an app-only PR
* must still pass. push is not classified; landing already happened.
*/
export function shouldRunLiveIsolation(eventName) {
return !eventName || eventName === "pull_request" || eventName === "merge_group";
}
export function usesPerCommitIsolation(eventName) {
return eventName === "merge_group";
}
/**
* File lists to run through check_app_terraform_isolation.py.
* `commitDiffs` is first-parent order (oldest first); ignored except on
* merge_group.
*/
export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) {
if (usesPerCommitIsolation(eventName)) {
return commitDiffs.map((commit) => commit.files);
}
return [rangeFiles];
}

View file

@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
@ -218,15 +220,79 @@ function runRepositoryGate(label, script) {
return { label, status: result.status, error: result.error };
}
function runIsolationGate(baseRef) {
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
function classifyIsolationPaths(files) {
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT,
encoding: "utf8",
input: `${files.join("\n")}\n`,
input: files.length > 0 ? `${files.join("\n")}\n` : "",
});
}
function firstParentCommitDiffs(baseRef) {
const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]);
return shas.map((sha) => ({
sha,
files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]),
}));
}
function runIsolationGate(baseRef, eventName) {
if (usesPerCommitIsolation(eventName)) {
const commits = firstParentCommitDiffs(baseRef);
return {
mode: "per-commit",
results: commits.map((commit) => ({
...classifyIsolationPaths(commit.files),
sha: commit.sha,
})),
};
}
// Diff from the merge base, not the moving base tip. A two-dot diff against
// a branch that has advanced reports everything the base gained after the
// branch point as if this change reverted it.
const mergeBase = gitText(["merge-base", baseRef, "HEAD"]);
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]);
return {
mode: "range",
mergeBase,
results: [{ ...classifyIsolationPaths(files), sha: null }],
};
}
function recordIsolationFailures(isolation, failures) {
const startError = isolation.results.find((result) => result.error);
if (startError) {
failures.push(`G13: could not start: ${startError.error.message}`);
}
if (isolation.results.some((result) => !result.error && result.status !== 0)) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
function logIsolationGate(baseRef, isolation) {
if (isolation.mode === "per-commit") {
console.log(
`G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`,
);
for (const result of isolation.results) {
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const prefix = result.sha ? result.sha.slice(0, 7) : "commit";
if (output) {
console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`);
}
}
return;
}
const mergeBase = isolation.mergeBase ?? "unresolvable";
console.log(
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
);
const result = isolation.results[0];
if (!result) return;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
@ -326,23 +392,30 @@ function main() {
}
console.log("─".repeat(64));
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`);
if (!baseRef) {
const eventName = process.env.GITHUB_EVENT_NAME;
if (!shouldRunLiveIsolation(eventName)) {
console.log(
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
);
} else if (!baseRef) {
console.log("G13: application and Terraform isolation (no base...HEAD)");
console.log(" FAIL (no valid base ref)");
failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
const isolation = runIsolationGate(baseRef);
if (isolation.error) {
console.log(" FAIL (could not start)");
failures.push(`G13: could not start: ${isolation.error.message}`);
} else {
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
if (isolation.status !== 0) {
failures.push("G13: do not mix deployable application files with terraform/");
}
let isolation;
try {
isolation = runIsolationGate(baseRef, eventName);
} catch (error) {
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
console.log(" FAIL (could not resolve isolation diffs)");
const message = error instanceof Error ? error.message : String(error);
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
}
if (isolation) {
logIsolationGate(baseRef, isolation);
recordIsolationFailures(isolation, failures);
}
}

View file

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
liveIsolationFileSets,
shouldRunLiveIsolation,
usesPerCommitIsolation,
} from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py");
const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"];
const APP_ONLY = ["src/app/routes.tsx"];
function classify(files) {
const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" });
return result.status;
}
function anySetFails(fileSets) {
return fileSets.some((files) => classify(files) !== 0);
}
describe("shouldRunLiveIsolation", () => {
it("runs locally and on pull_request", () => {
assert.equal(shouldRunLiveIsolation(undefined), true);
assert.equal(shouldRunLiveIsolation(""), true);
assert.equal(shouldRunLiveIsolation("pull_request"), true);
});
it("runs on merge_group so the required check classifies the candidate", () => {
assert.equal(shouldRunLiveIsolation("merge_group"), true);
assert.equal(usesPerCommitIsolation("merge_group"), true);
});
it("skips push and other CI events", () => {
assert.equal(shouldRunLiveIsolation("push"), false);
assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false);
assert.equal(usesPerCommitIsolation("pull_request"), false);
});
});
describe("liveIsolationFileSets", () => {
it("classifies the PR range as one change set", () => {
const range = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("pull_request", range, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [range]);
assert.equal(anySetFails(sets), true);
});
it("classifies each queued PR, not the merge-group union", () => {
const union = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", union, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]);
assert.equal(anySetFails(sets), false);
assert.equal(classify(union), 1);
});
it("fails a single queued PR that mixes terraform and app files", () => {
const mixed = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]);
assert.equal(anySetFails(sets), true);
});
});

View file

@ -30,7 +30,9 @@ export const API_PATHS = {
boardCreate: "workorders/board",
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -167,6 +169,8 @@ export const API_PATHS = {
},
services: {
list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",

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 { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
const theme = useTheme();
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<NotificationSessionProvider>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<Outlet />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
</Box>
</Box>
</Box>
</NotificationSessionProvider>
</ProtectedRoute>
);
}

View file

@ -1,104 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Link } from "react-router";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { Box } from "@mui/material";
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
import { PageHeader } from "@/components/ui/page-header";
export default function NotificationsPage() {
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "notifications"],
queryFn: vendorOperationsApi.notifications,
refetchInterval: 30_000,
});
const groups = data.reduce((result, item) => {
const items = result.get(item.group) ?? [];
items.push(item);
result.set(item.group, items);
return result;
}, new Map<string, typeof data>());
const center = useNotificationCenter();
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Items requiring attention
</Typography>
<Typography color="text.secondary">
Medium-severity vendor reminders refresh automatically. They do not create banners or
toasts.
</Typography>
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="Notifications"
actions={
<div className="flex items-center gap-3">
<NotificationFeedActions center={center} />
</div>
}
/>
<Box className="max-w-3xl px-5 pb-5">
<NotificationFeedList center={center} />
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Paper variant="outlined" className="p-6">
<Typography>No vendor alerts require attention.</Typography>
</Paper>
)}
{[...groups.entries()].map(([group, items]) => (
<Paper key={group} variant="outlined" className="p-4">
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
<Typography variant="h6">{group}</Typography>
<Chip label={items.length} size="small" />
</Stack>
<Stack spacing={2}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" className="p-3">
<Stack
direction={{ xs: "column", md: "row" }}
sx={{ gap: 2, justifyContent: "space-between" }}
>
<Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip label={item.severity} color="warning" size="small" />
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
</Stack>
<Typography color="text.secondary" className="mt-1">
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt != null && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action != null && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>
)}
{item.actions?.map((action) => (
<Button
key={action.href}
component={Link}
to={action.href}
variant="outlined"
>
{action.label}
</Button>
))}
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Paper>
))}
</Box>
);
}

View file

@ -0,0 +1,379 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Drawer,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
import {
SERVICE_WORK_ORDER_TYPES,
type CompletionDocTemplateOption,
type Service,
type ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
const ICONS = [
{ key: "wrench", label: "Wrench", Icon: Wrench },
{ key: "droplets", label: "Droplets", Icon: Droplets },
{ key: "zap", label: "Zap", Icon: Zap },
{ key: "hammer", label: "Hammer", Icon: Hammer },
{ key: "settings", label: "Settings", Icon: Settings2 },
] as const;
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
const Icon = icon.Icon;
return <Icon size={size} aria-hidden="true" />;
}
type DetailsProps = {
service: Service | null;
open: boolean;
canManage: boolean;
canDeactivate: boolean;
onClose: () => void;
onEdit: (service: Service) => void;
onDeactivate: (service: Service) => void;
};
export function ServiceDetailsDrawer({
service,
open,
canManage,
canDeactivate,
onClose,
onEdit,
onDeactivate,
}: DetailsProps) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: string[];
templates: CompletionDocTemplateOption[];
canChangeActiveState: boolean;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
onToggleType: (type: ServiceWorkOrderType) => void;
};
export function ServiceEditorDrawer({
mode,
form,
formError,
trades,
templates,
canChangeActiveState,
isSaving,
onClose,
onSave,
onUpdate,
onToggleType,
}: EditorProps) {
return (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<ServiceEditorFields
mode={mode}
form={form}
trades={trades}
templates={templates}
canChangeActiveState={canChangeActiveState}
onUpdate={onUpdate}
onToggleType={onToggleType}
/>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
type EditorFieldsProps = Pick<
EditorProps,
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
>;
function ServiceEditorFields({
mode,
form,
trades,
templates,
canChangeActiveState,
onUpdate,
onToggleType,
}: EditorFieldsProps) {
return (
<>
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
{mode === "edit" && canChangeActiveState && (
<FormControlLabel
control={
<Switch
checked={form.isActive ?? true}
onChange={(event) => onUpdate("isActive", event.target.checked)}
/>
}
label="Active"
/>
)}
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
getOptionLabel={(template) => template.name}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
renderInput={(params) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,279 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
type ServiceStatus,
} from "@/domain/services/use-cases/use-services-registry-controller";
import type { Service } from "@/domain/services/types/service";
import {
DeactivateServiceDialog,
ServiceDetailsDrawer,
ServiceEditorDrawer,
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
return (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search service or trade..."
className="flex-1"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
status={controller.status}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
canChangeActiveState={controller.canDeactivate}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function ServicesTable({
services,
onSelect,
}: {
services: Service[];
onSelect: (service: Service) => void;
}) {
return (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{services.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => onSelect(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
function EmptyServices({
search,
status,
onAdd,
}: {
search: boolean;
status: ServiceStatus;
onAdd?: () => void;
}) {
return (
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

View file

@ -0,0 +1,5 @@
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
return <ServicesRegistryView />;
}

View file

@ -0,0 +1,27 @@
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
export function formatUpliftDateTime(value: string): string {
if (!value) return "";
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasZone ? value : `${value}Z`);
if (Number.isNaN(date.getTime())) return "";
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
}
/**
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
* so only its date part is read and it never shifts with the viewer's time zone.
*/
export function formatUpliftCalendarDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (match == null) return "";
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
}

View file

@ -2,12 +2,27 @@ import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
function reportEvidenceError(error: unknown): void {
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
}
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch(reportEvidenceError);
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;
}
void upliftsApi
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
tab?.close();
reportEvidenceError(error);
});
}

View file

@ -1,4 +1,5 @@
import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
@ -7,21 +8,30 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
Tooltip,
} from "@mui/material";
import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable";
const BORDERED_BLOCK_SX = {
border: 1,
borderColor: "divider",
borderRadius: 1,
px: 1.75,
py: 1.25,
} as const;
type ModalCallbacks = {
onClose: () => void;
@ -31,24 +41,122 @@ type ModalCallbacks = {
onOpenAttachment: (row: UpliftQueueItem) => void;
};
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Text variant="label" tone="muted" className="block">
{label}
</Text>
{children}
<Text variant="body">{children}</Text>
</Box>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
function SubsectionTitle({ children }: { children: ReactNode }) {
return (
<Box>
<Text variant="heading" className="mb-1 block">
{title}
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
{children}
</Text>
);
}
function vendorTechnicianLabel(item: UpliftQueueItem): string {
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
}
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
return (
<Box component="section" aria-label="Work order">
<Text variant="heading" className="mb-2 block">
Work order
</Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
<DetailField label="Assigned Dispatcher">
{item.workOrderDispatcherName || "Unassigned"}
</DetailField>
<DetailField label="Scheduled">
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
</DetailField>
</Box>
</Box>
);
}
function JustificationBlock({ notes }: { notes: string }) {
const text = notes.trim();
return (
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
{text.length > 0 && <Text variant="body">{text}</Text>}
{text.length === 0 && (
<Text variant="description" tone="muted" className="italic">
No justification provided.
</Text>
)}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
}}
>
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : fallback}
</Text>
</Box>
);
}
function ApprovedOnWoBreakdown({
breakdown,
unavailable,
}: {
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
label="Auto-approved (within allowance)"
value={breakdown.auto}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Admin-approved"
value={breakdown.admin}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box>
);
}
@ -67,142 +175,61 @@ function UpliftAttachments({
</Text>
);
}
const canOpen = item.evidenceDocumentId != null;
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Chip
size="small"
variant="outlined"
label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
title={canOpen ? "Opens in a new tab" : undefined}
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
{extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
</Box>
);
}
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
function UpliftRequestSection({
item,
breakdown,
breakdownUnavailable,
onOpenAttachment,
}: {
item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<DetailSection title="Request">
<DetailField label="Amount">
<Text variant="body">
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
</Text>
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
<Box component="section" aria-label="Uplift request">
<Text variant="heading" className="mb-2 block">
Uplift request
</Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Requested By">
{item.requestedByVendorName || "Unknown requester"}
</DetailField>
<DetailField label="Requested At">
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
</DetailField>
</Box>
<SubsectionTitle>Justification</SubsectionTitle>
<JustificationBlock notes={item.vendorReason} />
<SubsectionTitle>Approved on WO</SubsectionTitle>
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
<SubsectionTitle>Attachments</SubsectionTitle>
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</Box>
);
}
@ -223,6 +250,19 @@ function PendingModalActions({
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
@ -236,73 +276,61 @@ function PendingModalActions({
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</>
);
}
function UpliftModalActions({
function RevokeModalAction({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
onReject,
onRevoke,
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const closed = item.workOrderClosed === true;
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<>
{item.status === "Pending" && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{item.status === "Approved" && (
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
)}
</>
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
);
}
export function UpliftDetailModal({
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});
const breakdown = useMemo<ExposureBreakdown>(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
return { breakdown, unavailable: woUpliftsQuery.isError };
}
function UpliftDetailContent({
item,
canRevoke,
approvePending,
@ -312,98 +340,102 @@ export function UpliftDetailModal({
onReject,
onRevoke,
onOpenAttachment,
}: {
item: UpliftQueueItem;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const { breakdown, unavailable } = useExposureBreakdown(item);
const isPending = item.status === "Pending";
const isApproved = item.status === "Approved";
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
return (
<>
<DialogTitle sx={{ pr: 6 }}>
<Text variant="title" as="div">
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
</Text>
<Text variant="description" tone="muted" when={subtitle.length > 0}>
{subtitle}
</Text>
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{ position: "absolute", top: 12, right: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
bgcolor: "action.hover",
borderRadius: 1,
px: 2,
py: 1.5,
mb: 2,
}}
>
<Text variant="label" tone="muted">
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />
<Divider sx={{ my: 2.5 }} />
<UpliftRequestSection
item={item}
breakdown={breakdown}
breakdownUnavailable={unavailable}
onOpenAttachment={onOpenAttachment}
/>
</DialogContent>
{(isPending || isApproved) && (
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
{isPending && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{isApproved && (
<RevokeModalAction
item={item}
canRevoke={canRevoke}
revokePending={revokePending}
onRevoke={onRevoke}
/>
)}
</DialogActions>
)}
</>
);
}
export function UpliftDetailModal({
item,
...rest
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
if (item == null) {
return null;
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
<UpliftDetailContent item={item} {...rest} />
</Dialog>
);
}

View file

@ -6,7 +6,10 @@ import {
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import {
openUpliftEvidence,
openUpliftEvidenceInNewTab,
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
onOpenAttachment={openUpliftEvidenceInNewTab}
/>
)}
<UpliftDecisionDialogs controller={controller} />

View file

@ -97,12 +97,13 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// Uploading a signed PDF finalizes through the same Generate flow, so the
// completedDate/address prerequisites still persist before docStatus Yes.
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,
onUploaded: dialog.handleGenerate,
onUploaded: dialog.markSignedPdfUploaded,
});
// A signed PDF on file means the document exists even if docState has not caught up.

View file

@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import type {
WorkOrderTablePatch,
@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean {
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
}
type StateSetter<T> = Dispatch<SetStateAction<T>>;
type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = {
onClose: () => void;
closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
openRequestKey?: number;
};
export function useSlideOverEditState({
function useSlideOverInitialization({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
initialTab,
setTab,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
setDraft,
setBaseline,
setEditing,
setShowUnsaved,
setUnsavedIntent,
}: {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab: SlideOverTab;
setTab: (tab: SlideOverTab) => void;
setDraft: StateSetter<WorkOrderTableRow | null>;
setBaseline: StateSetter<WorkOrderTableRow | null>;
setEditing: StateSetter<boolean>;
setShowUnsaved: StateSetter<boolean>;
setUnsavedIntent: StateSetter<"close" | "cancel-edit" | null>;
}) {
useEffect(() => {
if (!row) {
setDraft(null);
@ -59,12 +69,50 @@ export function useSlideOverEditState({
setDraft(row);
setBaseline(row);
setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info");
setTab(editMode ? "info" : initialTab);
setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
}
export function useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
initialTab = "info",
openRequestKey = 0,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useSlideOverInitialization({
row,
editMode,
initialTab,
setTab,
setDraft,
setBaseline,
setEditing,
setShowUnsaved,
setUnsavedIntent,
});
useEffect(() => {
if (row?.id == null || editMode) return;
setTab(initialTab);
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
@ -95,8 +143,7 @@ export function useSlideOverEditState({
});
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);

View file

@ -20,7 +20,6 @@ import {
isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -35,24 +34,21 @@ type UseWorkOrderSlideOverArgs = {
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
type CompletionUploadMutate = (
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
function uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate,
}: {
file: File;
row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate;
}) {
const version = infoSource?.rowVersion;
@ -60,27 +56,11 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
mutate({ file, workOrderVersion: version });
}
function resolveSlideOverSource(
@ -112,6 +92,8 @@ export function useWorkOrderSlideOver({
onSave,
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -160,6 +142,8 @@ export function useWorkOrderSlideOver({
onClose,
closeDisabled,
setTab,
initialTab,
openRequestKey,
});
const uploadCompletionPdf = (file: File) => {
@ -167,7 +151,6 @@ export function useWorkOrderSlideOver({
file,
row,
infoSource,
onSave,
mutate: uploadCompletionMutation.mutate,
});
};

View file

@ -11,7 +11,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -35,6 +35,8 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
export function WorkOrderSlideOver({
@ -52,8 +54,18 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
openRequestKey,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openRequestKey,
});
const { infoSource, activeDraft } = state;
return (

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import {
buildCompanyOptions,
buildTechnicianOptions,
@ -37,7 +37,7 @@ export function useVendorDialogForm({
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor();
const addTechnicians = useAddTechniciansToCompany();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
@ -119,7 +119,7 @@ export function useVendorDialogForm({
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -141,7 +141,7 @@ export function useVendorDialogForm({
companyVendor,
saveError,
canSubmit,
isPending: createVendor.isPending,
isPending: addTechnicians.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);

View file

@ -1,9 +1,8 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
canSubmit: boolean;
};
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
}: {
allVendors: VendorDropdownItem[];
pendingPayload: VendorFormValues;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
}): Promise<VendorDropdownItem> {
const anchor = allVendors.find(
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
);
if (!anchor) {
throw new Error("Select an existing vendor company before adding a technician.");
}
const [created] = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: [
{
contactName: pendingPayload.contactName,
phone: phone.trim() || (pendingPayload.phone ?? ""),
tradeSpecialties: pendingPayload.tradeSpecialties,
},
],
});
if (created == null) throw new Error("Failed to create technician");
return created;
}
export function useVendorDialogSave({
allVendors,
pendingCreates,
@ -30,7 +61,7 @@ export function useVendorDialogSave({
anchorCompanyName,
tech,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -53,11 +84,12 @@ export function useVendorDialogSave({
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
saveVendor = await persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);

View file

@ -51,6 +51,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
openRequestKey={page.slideOverOpenRequestKey}
users={users}
sites={locations}
vendors={vendors}

View file

@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
@ -63,7 +64,7 @@ export function useNewWoWizardController({
}
};
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs
@ -72,6 +73,11 @@ export function useNewWoWizardController({
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});
const createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,
createMutation: createWorkOrder,
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,

View file

@ -0,0 +1,93 @@
import { toast } from "react-toastify";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type {
WizardNewTechnician,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type UseWizardCreateWithTechniciansArgs = {
vendors: VendorDropdownItem[];
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
}
/** Draft changes applied once inline technicians exist as real vendor records. */
export function buildCreatedTechniciansPatch(
draft: WorkOrderWizardDraft,
company: string,
pending: WizardNewTechnician[],
created: VendorDropdownItem[],
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;
}
/**
* Persists technicians registered inline on the Vendor & time step under the
* selected company, then creates the work order. Nothing is written before Create, so
* cancelling the wizard leaves no stray vendor records.
*/
export function useWizardCreateWithTechnicians({
vendors,
patchDraft,
createMutation,
}: UseWizardCreateWithTechniciansArgs) {
const addTechnicians = useAddTechniciansToCompany();
const mutate = (draft: WorkOrderWizardDraft) => {
const pending = pendingTechnicians(draft);
const company = (draft.vendorCompany ?? "").trim();
if (pending.length === 0 || company === "") {
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
}
const anchor = vendors.find((vendor) => vendor.companyName === company);
if (!anchor) {
toast.error("Select an existing vendor company before adding technicians.");
return;
}
void (async () => {
try {
const created = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: pending.map((technician) => ({
contactName: technician.name,
phone: technician.phone,
tradeSpecialties: anchor.tradeSpecialties,
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
}
})();
};
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
}

View file

@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
nextPm !== draft.pm
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);

View file

@ -1,4 +1,5 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
@ -8,10 +9,7 @@ import {
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
getClosabilityGaps,
wizardDraftToClosabilityInput,
@ -30,7 +28,6 @@ import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
return (
<div className="space-y-5">
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
<WizardVendorCompanyFields
draft={draft}
vendors={vendors}
selectedVendor={selectedVendor}
onPatch={onPatch}
/>
{draft.vendorId !== "" && selectedVendor != null && (
<WizardVendorDetail

View file

@ -0,0 +1,141 @@
import { Building2, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
selectedVendor: VendorDropdownItem | undefined;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
const ICON_STYLE = { color: "var(--muted-foreground)" };
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
</span>
);
}
/** Company-first vendor selection with inline technician registration (prototype parity). */
export function WizardVendorCompanyFields({
draft,
vendors,
selectedVendor,
onPatch,
}: WizardVendorCompanyFieldsProps) {
const company = draft.vendorCompany || selectedVendor?.companyName || "";
const companyOptions = buildCompanyOptions(vendors, company);
const technicianOptions = vendors
.filter((vendor) => vendor.companyName === company)
.map((vendor) => ({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
return (
<>
<div>
<WizLabelRow
optional
onClear={
company === ""
? undefined
: () =>
onPatch({
vendorCompany: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
>
Company
</WizLabelRow>
<WizardFieldSelect
value={company}
placeholder="Select company…"
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
selectedVendor ? (
<TechnicianAvatar vendor={selectedVendor} />
) : (
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{company !== "" && (
<WizardVendorNewTechnicians
technicians={draft.newTechnicians ?? []}
onChange={(newTechnicians) => onPatch({ newTechnicians })}
/>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { X } from "lucide-react";
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardVendorNewTechniciansProps = {
technicians: WizardNewTechnician[];
onChange: (technicians: WizardNewTechnician[]) => void;
};
/**
* Inline technician registration on the wizard's Vendor & time step.
* Rows stay local to the draft; they are persisted under the selected company only
* when the work order is created, so cancelling the wizard never creates a vendor.
*/
export function WizardVendorNewTechnicians({
technicians,
onChange,
}: WizardVendorNewTechniciansProps) {
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<>
{technicians.map((technician, index) => {
const label = `New Technician ${index + 1}`;
return (
<div
key={index}
role="group"
aria-label={label}
style={{
padding: 10,
borderRadius: "var(--radius-md)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: "var(--color-text-muted)" }}
>
{label}
</span>
<button
type="button"
title="Remove technician"
aria-label={`Remove ${label}`}
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
style={{ color: "var(--color-text-muted)" }}
>
<X size={13} aria-hidden />
</button>
</div>
<div className="flex flex-col gap-2">
<div>
<WizSubLabel>Technician name</WizSubLabel>
<input
aria-label={`${label} name`}
value={technician.name}
onChange={(e) => update(index, { name: e.target.value })}
placeholder="Technician name"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
type="tel"
aria-label={`${label} phone`}
value={technician.phone}
onChange={(e) => update(index, { phone: e.target.value })}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
className="text-left text-[12px] font-medium"
style={{ color: "var(--color-primary)" }}
>
+ Add technician
</button>
</>
);
}

View file

@ -162,11 +162,22 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {
setGeneratedAt(fmtDocNow());
setDocState("generated");
refreshAuthoritativeState();
}
return {
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
markSignedPdfUploaded,
};
}

View file

@ -18,17 +18,19 @@ export function useDashboardDrilldownFilters(
}, [onApply]);
useEffect(() => {
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
// The applied params were cleared; a later drilldown link on this mounted page applies too.
appliedRef.current = false;
return;
}
if (appliedRef.current) {
return;
}
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
return;
}
appliedRef.current = true;
setFromDashboard(true);
setFromDashboard(searchParams.get("fromDashboard") === "1");
onApplyRef.current(drilldown);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);

View file

@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
parseWorkOrderDeepLink,
withoutWorkOrderDeepLink,
} from "@/domain/work-orders/utils/work-order-deep-link";
type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void;
/**
* Opens `?wo=<id>&tab=<tab>` in the slide-over once that work order's detail loads, whatever week
* the board is showing, then removes the params so closing the slide-over does not reopen it.
*/
export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
const [searchParams, setSearchParams] = useSearchParams();
const link = parseWorkOrderDeepLink(searchParams);
const workOrderId = link?.workOrderId;
const tab = link?.tab ?? "info";
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
const onOpenRef = useRef(onOpen);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
if (workOrderId == null) {
return;
}
const loaded = data != null && String(data.info.id) === workOrderId;
if (!loaded && error == null) {
return;
}
if (loaded) {
onOpenRef.current(data.info, false, tab);
}
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
}

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderLocations,
@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
function useSlideOverOpenState() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
const handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
setSlideOverOpenRequestKey((key) => key + 1);
};
return {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
};
}
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
} = useSlideOverOpenState();
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -74,11 +110,6 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
@ -130,6 +161,8 @@ export function useWorkOrdersListPage() {
wizardOpen,
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
const page = useWorkOrdersListPage();
const { filters, tableData } = page;
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink(page.handleOpenSlideOver);
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">

View file

@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [
{ path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
] as const;

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type UserMenuHeaderProps = {
initials: string;
fullname: string;
email: string;
};
function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) {
return (
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{initials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{fullname}
</Typography>
<Typography variant="caption" color="text.secondary">
{email}
</Typography>
</Box>
</Box>
);
}
export function AppTopbarUserMenu() {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<>
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<UserMenuHeader
initials={userInitials}
fullname={user?.fullname || "User"}
email={user?.email || ""}
/>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View file

@ -1,49 +1,14 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
AppBar,
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { AppBar, Box, Toolbar } from "@mui/material";
import { Menu as MenuIcon } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
type AppTopbarProps = {
onToggleNav: () => void;
};
export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<AppBar
position="sticky"
@ -97,135 +62,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<Box sx={{ flex: 1 }} />
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
</Toolbar>
<NotificationBell />
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
<AppTopbarUserMenu />
</Toolbar>
</AppBar>
);
}

View file

@ -0,0 +1,98 @@
import { useState } from "react";
import { Box, Drawer } from "@mui/material";
import { Bell, X } from "lucide-react";
import { Text } from "@/components/ui/text";
import { NotificationFeedActions } from "./notification-feed-actions";
import { NotificationFeedList } from "./notification-feed-list";
import { useNotificationCenter } from "./use-notification-center";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const center = useNotificationCenter(() => setOpen(false));
const unreadCount = center.unreadIds.size;
return (
<>
<Box
component="button"
type="button"
onClick={() => setOpen(true)}
title="Notifications"
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 38,
height: 38,
borderRadius: "6px",
border: "none",
cursor: "pointer",
overflow: "visible",
backgroundColor: "var(--color-header-surface)",
color: "var(--color-header-icon)",
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<Bell size={18} aria-hidden />
{unreadCount > 0 && (
<span
aria-hidden
className="flex items-center justify-center"
style={{
position: "absolute",
top: 2,
right: 2,
minWidth: 16,
height: 16,
padding: "0 4px",
borderRadius: 9999,
background: "var(--destructive)",
color: "var(--destructive-foreground)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1,
}}
>
{unreadCount}
</span>
)}
</Box>
<Drawer
anchor="right"
open={open}
onClose={() => setOpen(false)}
slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }}
>
<div
className="flex shrink-0 items-center gap-2"
style={{ padding: "16px 20px", borderBottom: "1px solid var(--color-border)" }}
>
<Text
as="h2"
variant="title"
sx={{ flex: 1, fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)" }}
>
Notifications
</Text>
<NotificationFeedActions center={center} />
<button
type="button"
onClick={() => setOpen(false)}
title="Close"
aria-label="Close notifications"
className="shrink-0 cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<X size={16} aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 20px 20px" }}>
<NotificationFeedList center={center} />
</div>
</Drawer>
</>
);
}

View file

@ -0,0 +1,33 @@
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
center: NotificationCenter;
};
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
return (
<>
<button
type="button"
onClick={center.markAllRead}
disabled={!hasUnread}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)" }}
>
Mark all read
</button>
<button
type="button"
onClick={center.dismissAll}
disabled={!hasItems}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-text-muted)" }}
>
Clear all
</button>
</>
);
}

View file

@ -0,0 +1,89 @@
import { CircularProgress } from "@mui/material";
import { Inbox } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { NotificationCenter } from "./use-notification-center";
import { NotificationRow } from "./notification-row";
type NotificationFeedListProps = {
center: NotificationCenter;
};
export function NotificationFeedList({ center }: NotificationFeedListProps) {
if (center.isLoading) {
return (
<div className="flex justify-center py-16">
<CircularProgress size={24} aria-label="Loading notifications" />
</div>
);
}
if (center.error != null && !center.hasData) {
return (
<Text variant="error" className="py-6">
Notifications could not be loaded. They will retry automatically.
</Text>
);
}
if (center.sections.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16">
<Inbox size={24} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="description" sx={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Nothing needs your attention
</Text>
</div>
);
}
return (
<div>
{center.sections.map((section) => (
<section
key={section.reason}
aria-label={`${section.label}, ${section.count}`}
style={{ marginTop: 16 }}
>
<div className="flex items-center gap-2" style={{ marginBottom: 4 }}>
<Text
as="h3"
variant="label"
sx={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--color-text-muted)",
}}
>
{section.label}
</Text>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--color-text-secondary)",
background: "var(--color-bg-muted)",
borderRadius: "var(--radius-sm)",
padding: "1px 6px",
}}
>
{section.count}
</span>
</div>
<ul className="m-0 list-none p-0">
{section.items.map((item) => (
<NotificationRow
key={item.id}
item={item}
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
/>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,127 @@
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Bell,
Clock,
Inbox,
MessageSquare,
ShieldAlert,
Wrench,
X,
} from "lucide-react";
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
"severity-critical": AlertTriangle,
sla: AlertTriangle,
mentions: MessageSquare,
unassigned: Inbox,
"due-soon": Clock,
"no-vendor": Wrench,
"aveta-missing": ShieldAlert,
activity: MessageSquare,
"vendor-conflict": Wrench,
};
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
};
/** 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) {
return null;
}
return (
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
{item.workOrders.map((workOrder) => {
const url = workOrderViewUrl(workOrder.id);
return (
<Link
key={workOrder.id}
to={url}
onClick={(event) => {
event.preventDefault();
onOpen(item, url);
}}
className="text-xs underline-offset-2 hover:underline"
style={{ color: "var(--color-primary)" }}
>
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
</Link>
);
})}
</span>
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
padding: "10px 8px",
marginInline: -8,
borderRadius: "var(--radius-md)",
borderBottom: "1px solid var(--color-bg-muted)",
}}
data-unread={unread}
>
<span className="flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<WorkOrderLinks item={item} onOpen={onOpen} />
</span>
{isDismissable(item) && (
<button
type="button"
onClick={() => onDismiss(item)}
title="Dismiss"
aria-label="Dismiss notification"
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
>
<X size={13} aria-hidden />
</button>
)}
</li>
);
}

View file

@ -0,0 +1,47 @@
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 { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
isDismissable,
visibleNotificationSections,
} from "@/domain/notifications/utils/visible-notification-sections";
export function useNotificationCenter(onNavigate?: () => void) {
const navigate = useNavigate();
const feed = useNotificationFeed();
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
const sections = useMemo(
() => visibleNotificationSections(feed.data?.sections ?? [], dismissedIds),
[feed.data, dismissedIds],
);
const items = useMemo(() => sections.flatMap((section) => section.items), [sections]);
const unreadIds = useMemo(
() => new Set(items.filter((item) => !readIds.has(item.id)).map((item) => item.id)),
[items, readIds],
);
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
markRead([item.id]);
if (url == null) return;
onNavigate?.();
navigate(url);
};
return {
sections,
unreadIds,
isLoading: feed.isLoading,
hasData: feed.data != null,
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};
}
export type NotificationCenter = ReturnType<typeof useNotificationCenter>;

View file

@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
(status) => status !== "Completed",
);
export function workOrderDrilldownUrl(search: URLSearchParams): string {
const query = search.toString();
export function workOrderDrilldownUrl(
search: URLSearchParams,
source: "dashboard" | "notification" = "dashboard",
): string {
const params = new URLSearchParams(search);
if (source === "dashboard") {
params.set("fromDashboard", "1");
}
const query = params.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
}

View file

@ -0,0 +1,10 @@
import { apiGet } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
} from "@/domain/notifications/types/notification-feed";
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
};

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
export type NotificationSession = {
dismissedIds: ReadonlySet<string>;
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
export function useNotificationSession(): NotificationSession {
const session = useContext(NotificationSessionContext);
if (!session) {
throw new Error("useNotificationSession must be used within a NotificationSessionProvider");
}
return session;
}

View file

@ -0,0 +1,38 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
if (ids.every((id) => current.has(id))) {
return current;
}
const next = new Set(current);
ids.forEach((id) => next.add(id));
return next;
}
/**
* Dismissed and read notification ids for this app session only. Nothing is persisted, so a
* reload starts over and a condition that still holds shows up again.
*/
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
}, []);
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
);
return (
<NotificationSessionContext.Provider value={value}>
{children}
</NotificationSessionContext.Provider>
);
}

View file

@ -0,0 +1,62 @@
import { z } from "zod";
/** Fixed reason order; it breaks ties between sections of equal severity. */
export const NOTIFICATION_REASON_ORDER = [
"severity-critical",
"sla",
"mentions",
"unassigned",
"due-soon",
"no-vendor",
"aveta-missing",
"activity",
"vendor-conflict",
] as const;
export const NOTIFICATION_SEVERITY_RANK = {
Critical: 3,
High: 2,
Medium: 1,
Low: 0,
} as const;
const severitySchema = z.enum(["Critical", "High", "Medium", "Low"]);
const targetSchema = z.object({
kind: z.enum(["workOrder", "queue"]),
workOrderId: z.number().nullish(),
tab: z.string().nullish(),
queue: z.string().nullish(),
});
const itemSchema = z.object({
id: z.string(),
reason: z.string(),
severity: severitySchema,
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
rowType: z.enum(["dismissable", "acknowledge"]),
title: z.string(),
count: z.number(),
triggeredAt: z.string(),
target: targetSchema,
workOrders: z.array(z.object({ id: z.number(), number: z.string().nullish() })),
});
const sectionSchema = z.object({
reason: z.string(),
label: z.string(),
severity: severitySchema,
count: z.number(),
items: z.array(itemSchema),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
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>;

View file

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import { queryKeys } from "@/infra/query-key/query-key";
/** Live conditions are recomputed server-side on every read, so polling keeps counts current. */
export const NOTIFICATION_FEED_REFRESH_MS = 30_000;
export function useNotificationFeed() {
return useQuery({
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
meta: { suppressErrorToast: true },
});
}

View file

@ -0,0 +1,46 @@
import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
export function unassignedQueueUrl(): string {
const search = new URLSearchParams();
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search, "notification");
}
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
export function chooseVendorUrl(workOrderId: number): string {
return `/workorders/edit/${workOrderId}?assignVendor=1`;
}
export function workOrderViewUrl(workOrderId: number): string {
return `/workorders/${workOrderId}`;
}
export function notificationTargetUrl(
item: Pick<NotificationItem, "reason" | "target">,
): string | null {
const { target } = item;
if (target.kind === "queue") {
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
}
if (target.workOrderId == null) {
return null;
}
if (item.reason === "no-vendor") {
return chooseVendorUrl(target.workOrderId);
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
}

View file

@ -0,0 +1,60 @@
import {
NOTIFICATION_REASON_ORDER,
NOTIFICATION_SEVERITY_RANK,
type NotificationItem,
type NotificationSection,
} from "@/domain/notifications/types/notification-feed";
const reasonOrder: ReadonlyMap<string, number> = new Map(
NOTIFICATION_REASON_ORDER.map((reason, index) => [reason, index]),
);
function reasonIndex(reason: string): number {
return reasonOrder.get(reason) ?? NOTIFICATION_REASON_ORDER.length;
}
function highestSeverity(items: NotificationItem[]): NotificationItem["severity"] {
return items.reduce<NotificationItem["severity"]>(
(highest, item) =>
NOTIFICATION_SEVERITY_RANK[item.severity] > NOTIFICATION_SEVERITY_RANK[highest]
? item.severity
: highest,
"Low",
);
}
export function isDismissable(item: NotificationItem): boolean {
return item.rowType === "dismissable";
}
/**
* Applies this session's dismissals to the server feed: dismissed rows leave, section counts drop
* by the work orders those rows represented, empty sections disappear, and sections re-sort by
* their highest remaining severity with the fixed reason order breaking ties.
*/
export function visibleNotificationSections(
sections: NotificationSection[],
dismissedIds: ReadonlySet<string>,
): NotificationSection[] {
return sections
.map((section) => {
const items = section.items.filter(
(item) => !isDismissable(item) || !dismissedIds.has(item.id),
);
const dismissedCount = section.items
.filter((item) => !items.includes(item))
.reduce((total, item) => total + item.count, 0);
return {
...section,
items,
count: Math.max(section.count - dismissedCount, 0),
severity: highestSeverity(items),
};
})
.filter((section) => section.items.length > 0)
.sort(
(left, right) =>
NOTIFICATION_SEVERITY_RANK[right.severity] - NOTIFICATION_SEVERITY_RANK[left.severity] ||
reasonIndex(left.reason) - reasonIndex(right.reason),
);
}

View file

@ -1,18 +1,48 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapServices } from "@/domain/services/mappers/service-mapper";
import type { Service } from "@/domain/services/types/service";
import {
mapCompletionDocTemplates,
mapService,
mapServices,
} from "@/domain/services/mappers/service-mapper";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const SERVICE_WORK_ORDER_TYPE_API = {
PM: 2,
Emergency: 3,
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
};
function toApiInput(input: ServiceInput) {
return {
...input,
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
),
};
}
export const servicesApi = {
getAll: async (isActive?: boolean): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: isActive === undefined ? undefined : { isActive },
});
return mapServices(handleApiResponse(data));
},
getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true },
@ -27,4 +57,23 @@ export const servicesApi = {
});
return mapServices(handleApiResponse(data));
},
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
return mapCompletionDocTemplates(handleApiResponse(data));
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
deactivate: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.services.deactivate(id));
},
};

View file

@ -1,62 +1,77 @@
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type {
CompletionDocTemplateOption,
LinkedServiceTemplate,
Service,
ServiceWorkOrderType,
} from "@/domain/services/types/service";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readId(item: Record<string, unknown>): string | number {
const value = item.id ?? item.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readString(item: Record<string, unknown>, ...keys: string[]): string {
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "string") return value.trim();
if (typeof value === "number") return String(value);
}
return "";
}
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "boolean") return value;
}
return false;
}
function mapWorkOrderType(value: unknown): WOType | null {
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
return null;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
if (!raw) return null;
const record = asRecord(raw);
const id = readId(record);
return id === "" ? null : { id, name: readString(record, "name", "Name") };
}
export function mapService(raw: unknown): Service {
const item = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
const record = asRecord(raw);
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
return {
id: readId(item),
name: readString(item, "name", "Name"),
trade: readString(item, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"),
id: readId(record),
name: readString(record, "name", "Name"),
trade: readString(record, "trade", "Trade"),
iconKey: readString(record, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
item,
record,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
isActive: readBool(item, "isActive", "IsActive"),
completionDocTemplate:
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
mapTemplate({
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
}),
isActive: readBool(record, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
}
export function mapServices(raw: unknown): Service[] {
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
const items = asRecord(raw);
const payload = Array.isArray(raw)
? raw
: (items.items ?? items.Items ?? items.data ?? items.Data);
return Array.isArray(payload)
? payload.map(mapService).filter((service) => service.id !== "")
: [];
}
export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
const payload = Array.isArray(raw)
? raw
: (() => {
const record = asRecord(raw);
return record.items ?? record.Items ?? record.data ?? record.Data;
})();
return Array.isArray(payload)
? payload.flatMap((item) => {
const record = asRecord(item);
const id = readId(record);
return id === ""
? []
: [
{
id,
name: readString(record, "name", "Name"),
} satisfies CompletionDocTemplateOption,
];
})
: [];
}

View file

@ -1,4 +1,13 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
export interface LinkedServiceTemplate {
id: string | number;
name: string;
}
export type CompletionDocTemplateOption = LinkedServiceTemplate;
export interface Service {
id: string | number;
@ -6,6 +15,17 @@ export interface Service {
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean;
supportedWorkOrderTypes: WOType[];
supportedWorkOrderTypes: ServiceWorkOrderType[];
}
export interface ServiceInput {
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplateId: number | null;
supportedWorkOrderTypes: ServiceWorkOrderType[];
isActive?: boolean;
}

View file

@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from "react";
import { useAuthContext } from "@/providers/auth-context";
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
import {
useCreateService,
useCompletionDocTemplates,
useDeactivateService,
useServices,
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
export const EMPTY_SERVICE_FORM: ServiceForm = {
name: "",
trade: "",
iconKey: "",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
Electrical: "zap",
"General Building & Handyman": "hammer",
};
export function buildServiceInput(
form: ServiceForm,
includeActiveState = false,
): {
input: ServiceInput | null;
error: string;
} {
const name = form.name.trim();
if (!name || !form.trade || !form.iconKey) {
return { input: null, error: "Service Name, Trade, and Icon are required." };
}
if (!form.supportedWorkOrderTypes.length) {
return { input: null, error: "Select at least one Work Order Type." };
}
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
return {
input: null,
error: "Select a completion document template when the document is required.",
};
}
const templateId = form.completionDocTemplateId;
const { isActive, ...editableFields } = form;
return {
input: {
...editableFields,
name,
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
},
error: "",
};
}
function emptyForm(): ServiceForm {
return {
...EMPTY_SERVICE_FORM,
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
};
}
function formFromService(service: Service): ServiceForm {
return {
name: service.name,
trade: service.trade,
iconKey: service.iconKey,
requiresCompletionDocument: service.requiresCompletionDocument,
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
isActive: service.isActive,
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
createService: ReturnType<typeof useCreateService>,
updateService: ReturnType<typeof useUpdateService>,
) {
const [selected, setSelected] = useState<Service | null>(null);
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
}
return canonicalTrades;
}, [canonicalTrades, form.trade]);
useEffect(() => {
if (!iconTouched && form.trade) {
setForm((current) => ({
...current,
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
}));
}
}, [form.trade, iconTouched]);
const openCreate = () => {
setSelected(null);
setForm(emptyForm());
setIconTouched(false);
setFormError("");
setEditorMode("add");
};
const openEdit = (service: Service) => {
setSelected(service);
setForm(formFromService(service));
setIconTouched(true);
setFormError("");
setEditorMode("edit");
};
const closeEditor = () => {
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
};
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
if (key === "trade") setIconTouched(false);
setFormError("");
setForm((current) => ({ ...current, [key]: value }));
};
const toggleType = (type: ServiceWorkOrderType) => {
setForm((current) => {
const supported = current.supportedWorkOrderTypes.includes(type)
? current.supportedWorkOrderTypes.filter((item) => item !== type)
: [...current.supportedWorkOrderTypes, type];
return { ...current, supportedWorkOrderTypes: supported };
});
};
const save = () => {
const validation = buildServiceInput(form, canDeactivate);
if (!validation.input) {
setFormError(validation.error);
return;
}
const input = validation.input;
if (editorMode === "add") {
createService.mutate(input, {
onSuccess: (created) => {
setEditorMode(null);
setSelected(created);
},
});
} else if (editorMode === "edit" && selected) {
updateService.mutate(
{ id: selected.id, input },
{
onSuccess: (updated) => {
setEditorMode(null);
setSelected(updated);
},
},
);
}
};
return {
selected,
setSelected,
editorMode,
form,
formError,
trades,
openCreate,
openEdit,
closeEditor,
updateForm,
toggleType,
save,
};
}
export function useServicesRegistryController() {
const { user } = useAuthContext();
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
const canDeactivate = isAdminUser(user?.userRoles);
const [status, setStatus] = useState<ServiceStatus>("active");
const [search, setSearch] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService);
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
const confirmDeactivate = () => {
if (!deactivateTarget) return;
deactivateService.mutate(deactivateTarget.id, {
onSuccess: () => {
setDeactivateTarget(null);
editor.setSelected(null);
},
});
};
return {
canManage,
canDeactivate,
status,
setStatus,
search,
setSearch,
selected: editor.selected,
setSelected: editor.setSelected,
editorMode: editor.editorMode,
form: editor.form,
formError: editor.formError,
trades: editor.trades,
templates,
filteredServices,
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate: editor.openCreate,
openEdit: editor.openEdit,
closeEditor: editor.closeEditor,
updateForm: editor.updateForm,
toggleType: editor.toggleType,
save: editor.save,
deactivateTarget,
setDeactivateTarget,
confirmDeactivate,
isSaving: createService.isPending || updateService.isPending,
isDeactivating: deactivateService.isPending,
};
}

View file

@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { servicesApi } from "@/domain/services/api/services-api";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(isActive),
queryFn: () => servicesApi.getAll(isActive),
staleTime: 60 * 1000,
});
}
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
return useQuery({
queryKey: queryKeys.services.completionTemplates(),
queryFn: servicesApi.getCompletionTemplates,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInput) => servicesApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service added");
},
onError: (error: Error) => toast.error(error.message || "Failed to add service"),
});
}
export function useUpdateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
servicesApi.update(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service updated");
},
onError: (error: Error) => toast.error(error.message || "Failed to update service"),
});
}
export function useDeactivateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => servicesApi.deactivate(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service deactivated");
},
onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
});
}

View file

@ -106,28 +106,61 @@ export const upliftsApi = {
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
let response: Response;
try {
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName);
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
/**
* Renders the evidence in `tab`, which the caller opens synchronously on click so
* popup blockers allow it. Only inert types render inline on the app origin; any
* other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
*/
openEvidence: async (
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
tab?.close();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
return;
}
const url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
},
};
const INLINE_EVIDENCE_TYPES = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]);
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
}
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
"trade",
"Trade",
),
technicianName: readString(item, "technicianName", "TechnicianName"),
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
attachmentCount: readNumber(
item,
"attachmentCount",

View file

@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
woNumber: string;
site: string;
serviceName: string;
technicianName: string;
workOrderDispatcherName: string;
workOrderScheduledDate: string;
attachmentCount: number | null;
approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null;

View file

@ -5,7 +5,6 @@ import type {
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
}
export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: {
start?: string;
end?: string;

View file

@ -1,28 +1,3 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability {
id: number;
companyName: string;

View file

@ -0,0 +1,96 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
export interface NewCompanyTechnician {
contactName: string;
phone: string;
tradeSpecialties?: string;
}
export interface AddTechniciansToCompanyInput {
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
anchorVendorId: string | number;
technicians: NewCompanyTechnician[];
}
function takeAddedTechnician(
added: VendorRosterTechnician[],
contactName: string,
): VendorRosterTechnician {
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
if (index < 0) {
throw new Error("The technician was saved but could not be matched. Reload and try again.");
}
const [match] = added.splice(index, 1);
return match as VendorRosterTechnician;
}
/**
* Adds technicians to an existing vendor company through the additive roster PATCH.
*
* Work-order entry points used to POST a whole new vendor with blank company
* fields, which the create validator rejects (company phone/email) and which would
* overwrite company data. The additive path only appends technicians, keeps the
* company record untouched, and returns the persisted technician ids.
*/
export async function addTechniciansToCompany({
anchorVendorId,
technicians,
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
if (roster.companyId == null || roster.rowVersion === "") {
throw new Error("Vendor company could not be loaded. Try again.");
}
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
rowVersion: roster.rowVersion,
addTechnicians: technicians.map((technician) => {
const phone = technician.phone.trim();
return {
contactName: technician.contactName.trim(),
phone,
email: "",
tradeSpecialties: technician.tradeSpecialties ?? "",
isActive: true,
...(phone === "" ? {} : { preferredContact: "Phone" }),
};
}),
});
const added = saved.technicians.filter(
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
);
return technicians.map((technician) => {
const match = takeAddedTechnician(added, technician.contactName.trim());
const email = match.email.trim();
return {
id: match.id ?? "",
companyName: saved.name || roster.name,
contactName: match.contactName,
tradeSpecialties: match.tradeSpecialties,
address: saved.address,
distanceMiles: null,
email: email || undefined,
};
});
}
export function useAddTechniciansToCompany(): UseMutationResult<
VendorDropdownItem[],
Error,
AddTechniciansToCompanyInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTechniciansToCompany,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
});
}

View file

@ -1,21 +0,0 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
},
});
}

View file

@ -216,10 +216,14 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false },
{ throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);

View file

@ -0,0 +1,81 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiRequestRaw } from "@/api/api";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderBoardPatchPayload,
WorkOrderPocPatchPayload,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/** Shared request/error handling for board row PATCH endpoints (board + poc). */
async function patchBoardRow(
operationName: string,
method: "patch",
url: string,
payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> {
const response = await apiRequestRaw(
method,
url,
{ json: payload, throwHttpErrors: false },
operationName,
);
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
}
export const workOrderBoardPatchApi = {
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow(
"workOrdersApi.patchBoardField",
"patch",
API_PATHS.workOrder.boardPatch(id),
payload,
),
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: async (
id: string | number,
payload: WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
};

View file

@ -1,12 +1,8 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
import {
mapDispatchComment,
mapDispatchDetail,
@ -20,7 +16,6 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData,
mapWorkOrderTableRow,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
@ -42,7 +37,6 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams,
@ -310,51 +304,10 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
patchBoardField: workOrderBoardPatchApi.patchBoardField,
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record =
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
},
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: workOrderBoardPatchApi.updatePoc,
update: async (
id: string | number,

View file

@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"techPhone",
"location",
"locationId",
"pocName",
"pocPhone",
"pocNotes",
"title",
"priority",
"apptStart",
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
}
/** Composite manual POC override op routed to PATCH …/poc. */
export type BoardPocOp = {
kind: "poc";
pocName?: string;
pocPhone?: string;
pocNotes?: string;
};
export type BoardOp = BoardFieldOp | BoardPocOp;
function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
const hasPocFields =
patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined;
if (!hasPocFields) {
return [];
}
return [
{
kind: "poc",
pocName: patch.pocName,
pocPhone: patch.pocPhone,
pocNotes: patch.pocNotes,
},
];
}
/**
* Expands a UI table patch into ordered board field ops (one PATCH each).
* Schedule/week-only fields are emitted first in a stable order.
* Expands a UI table patch into ordered board ops (one request each).
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];
}
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
}
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
primaryDispatchId?: number | null;
}
/** Payload for PATCH /workorders/{id}/poc (manual POC override). */
export interface WorkOrderPocPatchPayload {
pocName: string;
pocPhone: string;
pocNotes: string;
workOrderVersion: string;
}
export type WorkOrderBoardEditableField =
| "woNumber"
| "workOrderType"

View file

@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export type WizardSchedMode = "date" | "week" | "range";
export interface WizardNewTechnician {
name: string;
phone: string;
}
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
avetaRequired: boolean;
vendorId: string;
vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string;
vendorNotes: string;
apptStart: string;
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
avetaRequired: false,
vendorId: "",
vendorName: "",
vendorCompany: "",
newTechnicians: [],
techPhone: "",
vendorNotes: "",
apptStart: "",

View file

@ -14,6 +14,7 @@ import {
buildBoardPatchPayload,
expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch,
type BoardOp,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
return "kind" in op && op.kind === "poc";
}
async function applyBoardOp(
id: string | number,
op: BoardOp,
row: WorkOrderTableRow,
): Promise<WorkOrderTableRow> {
if (isPocOp(op)) {
if (!row.rowVersion) {
throw new Error("workOrderVersion is required for board patch");
}
return workOrdersApi.updatePoc(id, {
pocName: op.pocName ?? row.pocName,
pocPhone: op.pocPhone ?? row.pocPhone,
pocNotes: op.pocNotes ?? row.pocNotes,
workOrderVersion: row.rowVersion,
});
}
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
latest = await applyBoardOp(id, op, latest);
completedOps += 1;
} catch (error) {
if (completedOps > 0) {

View file

@ -7,7 +7,8 @@ import type {
/**
* Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName).
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
* Primary POC scalars persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field).
*/
export const SLIDE_OVER_EDIT_KEYS = [
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
"vendorId",
// company/tech are display companions; board persists vendor via vendorId only
"status",
"pocName",
"pocPhone",
"pocNotes",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];

View file

@ -0,0 +1,48 @@
import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown-links";
const WORK_ORDER_PARAM = "wo";
const TAB_PARAM = "tab";
export const WORK_ORDER_DEEP_LINK_TABS = [
"info",
"comments",
"audit",
"completion",
"extras",
] as const;
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
export type WorkOrderDeepLink = {
workOrderId: string;
tab: WorkOrderDeepLinkTab;
};
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
}
/** `/workorders?wo=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
if (isDeepLinkTab(tab ?? null)) {
search.set(TAB_PARAM, String(tab));
}
return `${WORK_ORDERS_ROUTE}?${search.toString()}`;
}
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
if (!/^\d+$/.test(workOrderId)) {
return null;
}
const tab = params.get(TAB_PARAM);
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
}
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(params);
next.delete(WORK_ORDER_PARAM);
next.delete(TAB_PARAM);
return next;
}

View file

@ -75,6 +75,8 @@ export const queryKeys = {
},
services: {
all: ["services"] as const,
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
@ -147,6 +149,10 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
sites: () => [...queryKeys.assets.all, "sites"] as const,
},
notifications: {
all: ["notifications"] as const,
feed: () => [...queryKeys.notifications.all, "feed"] as const,
},
vendorPortal: {
all: ["vendorPortal"] as const,
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,

View file

@ -6,13 +6,18 @@ export function getPrimaryUserRole(
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
return hasUserRole(userRoles, "admin");
}
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
if (!userRoles) {
return false;
}
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes("admin");
.includes(normalizedExpectedRole);
}
/**

View file

@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
@ -161,7 +162,7 @@ describe("DashboardPage", () => {
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));

View file

@ -0,0 +1,153 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
import type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
const useServices = vi.fn();
const mutate = vi.fn();
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
};
});
vi.mock("@/domain/services/use-cases/use-services", () => ({
useServices: (...args: unknown[]) => useServices(...args),
useCompletionDocTemplates: () => ({ data: [] }),
useCreateService: () => ({ mutate, isPending: false }),
useUpdateService: () => ({ mutate, isPending: false }),
useDeactivateService: () => ({ mutate, isPending: false }),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({
data: {
companies: [],
trades: ["Plumbing & Water Systems", "Electrical", "HVAC", "General Building & Handyman"],
areas: [],
locations: [],
jobBuckets: [],
},
}),
}));
const activeHvac: Service = {
id: 1,
name: "HVAC Repair",
trade: "HVAC",
iconKey: "zap",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive"],
};
const activeDrain: Service = {
...activeHvac,
id: 2,
name: "Drain Cleaning",
trade: "Plumbing",
};
const inactiveFire: Service = {
...activeHvac,
id: 3,
name: "Fire Suppression",
trade: "Fire Protection",
isActive: false,
};
function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
useServices.mockReturnValue({
data: services,
isLoading: false,
error: null,
});
return renderWithProviders(<ServicesRegistryView />, { route: "/settings/services" });
}
describe("ServicesRegistryView", () => {
it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
setup();
expect(useServices).toHaveBeenCalledWith();
expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("switches to the Inactive tab and shows only inactive services", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 inactive service")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("applies the search within the selected tab and reports the filtered footer count", async () => {
setup();
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
expect(screen.getByText("No services found")).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("offers only canonical trades and sends the selected canonical trade to the create payload", async () => {
setup();
await userEvent.click(screen.getByRole("button", { name: "Add Service" }));
await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap");
await userEvent.click(screen.getByRole("combobox", { name: "Trade" }));
// Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not.
expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: "Electrical" }));
const saveButtons = screen.getAllByRole("button", { name: "Add Service" });
await userEvent.click(saveButtons[saveButtons.length - 1]);
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate.mock.calls[0][0]).toMatchObject({ trade: "Electrical" });
});
it("summarizes the filtered count and the overall split in the footer without a search", () => {
setup();
const footer = screen.getByText("2 active services").closest("div");
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show more