Compare commits

...

98 commits

Author SHA1 Message Date
Alexandre Brandizzi
99481c49e9
Merge branch 'dev' into feat/ab/sh-207-uplift-queue-frontend 2026-09-17 14:34:27 -03:00
Alexandre Brandizzi
6420b0d091
Merge pull request #214 from Sea-Haven-Industries/feat/ab/sh-325-add-team-member
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 dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-325: add team member form
2026-09-17 14:32:23 -03:00
Alexandre Brandizzi
1a13787ecb
Merge branch 'dev' into feat/ab/sh-207-uplift-queue-frontend 2026-09-17 14:26:21 -03:00
Alexandre Brandizzi
a81fd14d7d
Merge branch 'dev' into feat/ab/sh-325-add-team-member 2026-09-17 14:20:16 -03:00
Alexandre Brandizzi
c0b511ed12
Merge pull request #218 from Sea-Haven-Industries/fix/ab/sh-375-edit-error-copy
SH-375: keep backend validation text out of work-order edit errors
2026-09-17 14:18:12 -03:00
Alexandre Brandizzi
8d2adb4472
Merge branch 'dev' into fix/ab/sh-375-edit-error-copy 2026-09-17 14:04:19 -03:00
Codex Review Integration
a65f282b51 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member 2026-09-17 13:47:06 -03:00
Alexandre Brandizzi
1e1b58c560
Merge pull request #211 from Sea-Haven-Industries/feat/ab/sh-324-team-members-list
SH-324: add searchable team member registry
2026-09-17 13:42:13 -03:00
Alexandre Brandizzi
d6518bc065
Merge branch 'dev' into feat/ab/sh-324-team-members-list 2026-09-17 13:28:10 -03:00
Alexandre Brandizzi
04a248fd53
Merge pull request #210 from Sea-Haven-Industries/feat/ab/sh-187-service-picker
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 dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-187: Use Services registry in work-order picker
2026-09-17 13:14:09 -03:00
Alexandre Brandizzi
c991c9e89f
Merge branch 'dev' into feat/ab/sh-187-service-picker 2026-09-17 13:02:18 -03:00
Alexandre Brandizzi
56a7895950
Merge pull request #206 from Sea-Haven-Industries/fix/ab/sh-320-inline-sh-prefix-wo-number
SH-320: accept SH-prefixed placeholder work-order numbers
2026-09-17 13:00:19 -03:00
Alexandre Brandizzi
8427182209
Merge branch 'dev' into feat/ab/sh-187-service-picker 2026-09-17 12:58:17 -03:00
Alexandre Brandizzi
a9247a1997
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-17 12:50:18 -03:00
Codex Review Integration
b551421115 fix(work-orders): show duplicate-WO copy for edit conflicts (SH-375)
A duplicate work order number edit returns 422 DuplicateWoNumber from the
board PATCH endpoint, which mapped to the generic save-failed copy. Add the
code to the known validation copy map so edits reuse the same duplicate-WO
product text as create, and share the message constant across both paths.
2026-09-17 06:15:23 -03:00
Codex Review Integration
5c093e8c26 Merge remote-tracking branch 'origin/dev' into fix/ab/sh-375-edit-error-copy 2026-09-17 05:59:25 -03:00
Codex Review Integration
824001dc5e fix(work-orders): make service picker search placeholder type-neutral 2026-09-17 01:40:58 -03:00
Codex Review Integration
7cef338e34 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-324-team-members-list
# Conflicts:
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-17 00:49:09 -03:00
Alexandre Brandizzi
6972972afe test(visual): regenerate visual baselines for team members nav group 2026-09-17 00:30:36 -03:00
Codex Review Integration
16a2ab5511 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member 2026-09-17 00:06:18 -03:00
Codex Review Integration
1207df6ed8 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member
# Conflicts:
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-17 00:04:46 -03:00
Alexandre Brandizzi
5e4999b4f2
Merge pull request #207 from Sea-Haven-Industries/fix/ab/sh-359-wo-board-column-order
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 dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-359: place Completed Date before Comp Doc
2026-09-16 23:54:15 -03:00
Alexandre Brandizzi
bc40c17285
Merge branch 'dev' into fix/ab/sh-359-wo-board-column-order 2026-09-16 23:44:07 -03:00
Codex Review Integration
a24b43b219 test(team-members): refresh visual baselines (SH-325) 2026-09-16 23:15:17 -03:00
Codex Review Integration
0ac8c4473e fix(team-members): harden team member flows (SH-325) 2026-09-16 23:14:51 -03:00
Codex Review Integration
ae255f59a6 fix(team-members): destructure load error prop (SH-324) 2026-09-16 22:58:46 -03:00
Codex Review Integration
9160dd472a test(work-orders): refresh startup visual baselines (SH-324) 2026-09-16 22:53:03 -03:00
Codex Review Integration
a4ce537ed6 test(work-orders): tolerate cold visual page startup (SH-324) 2026-09-16 22:50:55 -03:00
Codex Review Integration
4bee428c71 test(team-members): refresh sidebar visual baselines (SH-324) 2026-09-16 22:39:09 -03:00
Codex Review Integration
1df3e5d292 fix(team-members): render load failures distinctly (SH-324) 2026-09-16 22:38:53 -03:00
Codex Review Integration
49923c9a02 test(work-orders): target the Insert WO # placeholder in the SH number e2e
The inline create row now renders the empty WO # cell as a required
"Insert WO # *" field instead of "-", so the spec waited for a cell that
no longer exists.
2026-09-16 22:07:46 -03:00
Alexandre Brandizzi
e6f5199da9
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 21:56:20 -03:00
Alexandre Brandizzi
ffaa91bb1b
Merge pull request #194 from Sea-Haven-Industries/feat/ab/sh-222-create-error-copy
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 dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-222: keep backend validation text out of create error toasts
2026-09-16 21:54:18 -03:00
Codex Review Integration
2d54ff12f8 fix(work-orders): show fixed copy for edit conflict toasts (SH-375) 2026-09-16 21:22:07 -03:00
Codex Review Integration
afc98a9daa fix(work-orders): normalize partial-save and generic edit error toasts (SH-375) 2026-09-16 21:20:16 -03:00
Codex Review Integration
fc25d8f7c7 fix(work-orders): show generic copy for unknown edit errors (SH-375) 2026-09-16 21:14:25 -03:00
Alexandre Brandizzi
440a3d0112
Merge branch 'dev' into feat/ab/sh-222-create-error-copy 2026-09-16 20:56:26 -03:00
Alexandre Brandizzi
c0d3300ab1
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 20:48:14 -03:00
Alexandre Brandizzi
9fb2cc9702
Merge pull request #208 from Sea-Haven-Industries/fix/ab/sh-369-completion-doc-service-gate
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 dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-369: keep completion document disabled until a service is selected
2026-09-16 20:44:14 -03:00
Alexandre Brandizzi
6682fcd7c2
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 20:30:17 -03:00
Alexandre Brandizzi
5d5581209a
Merge branch 'dev' into fix/ab/sh-369-completion-doc-service-gate 2026-09-16 20:30:14 -03:00
Alexandre Brandizzi
f25aea8bac
Merge pull request #200 from Sea-Haven-Industries/feat/ab/sh-278-vendor-service-area
SH-278: add vendor Service Area assignment, filter and column
2026-09-16 20:26:17 -03:00
Alexandre Brandizzi
fc21321148
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 20:16:18 -03:00
Alexandre Brandizzi
7aa964aa0f
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 20:16:15 -03:00
Alexandre Brandizzi
20662b57c1
Merge branch 'dev' into fix/ab/sh-369-completion-doc-service-gate 2026-09-16 20:16:13 -03:00
Codex Review Integration
9722805300 Merge remote-tracking branch 'origin/dev' into fix/ab/sh-369-completion-doc-service-gate
# Conflicts:
#	src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
2026-09-16 20:03:04 -03:00
Alexandre Brandizzi
e272cabe34
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 20:00:16 -03:00
Alexandre Brandizzi
85a6dad0f9
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 20:00:13 -03:00
Codex Review Integration
b6f4590bfa feat(team-members): add member creation form (SH-325) 2026-09-16 19:49:27 -03:00
Alexandre Brandizzi
1c1c6b5670
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 19:46:26 -03:00
Alexandre Brandizzi
ace66f52e5
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 19:46:22 -03:00
Alexandre Brandizzi
ef199e19b6
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 19:34:20 -03:00
Codex Review Integration
71756ca3a5 fix(work-orders): clear registry service when WO type changes
Selecting a work-order type on wizard step 1 only patched type and
severity, leaving pm, serviceId, extraServices, and the vendor filtered
by that service in the draft. Switching type (e.g. PM -> Reactive) then
submitted a serviceId that is not in the new type's registry list, which
backend ResolveServiceAsync (SH-187/#131) rejects as ServiceInvalid and
the create toast renders as the generic "Unable to create the work
order". Clear the service and its vendor whenever the type actually
changes; re-selecting the same type leaves the selection untouched.
2026-09-16 19:33:08 -03:00
Alexandre Brandizzi
40301e23ab
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 19:32:20 -03:00
Alexandre Brandizzi
331de59f97
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 19:18:25 -03:00
Alexandre Brandizzi
a793e1c2d9
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 19:18:18 -03:00
Alexandre Brandizzi
0b219311b5
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 19:04:23 -03:00
Alexandre Brandizzi
b120b43b0f
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 19:04:14 -03:00
Codex Review Integration
de79daefb1 feat(team-members): add searchable team member registry (SH-324) 2026-09-16 18:54:33 -03:00
Alexandre Brandizzi
7876b6ca42
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 18:52:29 -03:00
Alexandre Brandizzi
cd611fd09c
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 18:52:23 -03:00
Alexandre Brandizzi
42b5642fec
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 18:38:29 -03:00
Alexandre Brandizzi
551ed863f4
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 18:38:20 -03:00
Alexandre Brandizzi
2152d2d31c
Merge branch 'dev' into fix/ab/sh-369-completion-doc-service-gate 2026-09-16 18:38:17 -03:00
Alexandre Brandizzi
ac2642d5b9
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 18:28:25 -03:00
Alexandre Brandizzi
c135605c87
Merge branch 'dev' into fix/ab/sh-369-completion-doc-service-gate 2026-09-16 18:28:22 -03:00
Codex Review Integration
3a902bfebe feat(work-orders): use services registry picker 2026-09-16 18:26:55 -03:00
Alexandre Brandizzi
daaf588793
Merge branch 'dev' into feat/ab/sh-278-vendor-service-area 2026-09-16 18:26:26 -03:00
Adam Moussa
e95a720630
Merge branch 'dev' into feat/ab/sh-222-create-error-copy 2026-09-16 16:46:27 -04:00
Codex Review Integration
38ea40d6ca Merge origin/dev into feat/ab/sh-278-vendor-service-area 2026-09-16 16:08:12 -03:00
Codex Review Integration
9225f2345c fix(work-orders): gate completion document button on service selection (SH-369)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:23:17 -03:00
Codex Review Integration
4eb929e1c8 test(work-orders): remove query devtools overlay after navigation
Also drops ticket keys from shipped comments and test names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:15:52 -03:00
Alexandre Brandizzi
2d8a35a08a
Merge branch 'dev' into fix/ab/sh-359-wo-board-column-order 2026-09-16 15:08:07 -03:00
Codex Review Integration
62825d2c70 chore(workorders): remove ticket keys from column comments 2026-09-16 15:05:25 -03:00
Codex Review Integration
4649fd00da fix(work-orders): place Completed Date before Comp Doc on the board 2026-09-16 13:21:48 -03:00
Codex Review Integration
050e3f5134 test(work-orders): cover SH placeholder WO numbers on the board 2026-09-16 13:13:35 -03:00
Codex Review Integration
45b988c522 fix(work-orders): accept SH placeholder numbers in the inline WO number field 2026-09-16 13:12:24 -03:00
Codex Review Integration
88ba480522 test(vendors): refresh visual baselines for Service Area 2026-09-16 12:05:07 -03:00
Codex Review Integration
2bff7176be feat(vendors): add Service Area assignment, filter and column
Replace the free-text vendor State with a closed 50-state list, add an
admin-only Service Area select below Notes, a Service Area advanced filter
(stable ids plus Unassigned) and a Service Area directory column.
2026-09-16 11:55:40 -03:00
Codex Review Integration
3ad07c9e10 fix(work-orders): keep backend validation text out of create error toasts
Board create failures toasted the backend message verbatim, exposing
field names such as siteCode and vendorId from the inline row and the
wizard. Map known codes to product copy and fall back to generic copy
for every other failure.
2026-09-16 11:17:37 -03:00
Codex Review Integration
83df262ab6 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory 2026-09-15 19:12:21 -03:00
Codex Review Integration
b83514a240 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory
# Conflicts:
#	e2e/vendors/vendors.spec.ts
#	src/app/(protected)/vendors/_components/vendors-table.tsx
#	src/test/app/(protected)/vendors/vendors-list.test.tsx
2026-09-15 19:08:53 -03:00
Codex Review Integration
1f3a8f939f test(vendors): refresh combined visual snapshots 2026-09-15 16:40:59 -03:00
Codex Review Integration
fa1f4467e2 feat(vendors): merge dev after address changes 2026-09-15 16:15:04 -03:00
Codex Review Integration
158e1740df Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory 2026-09-15 16:04:42 -03:00
Codex Review Integration
c8c7533ee7 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory 2026-09-15 14:07:08 -03:00
Codex Review Integration
af6616ca65 test(vendors): follow grouped directory endpoint 2026-09-14 15:51:00 -03:00
Codex Review Integration
99253587ef fix(vendors): use grouped directory endpoint 2026-09-14 15:24:55 -03:00
Codex Review Integration
b1ae5085e7 test(vendors): align centered empty baseline 2026-09-14 13:34:57 -03:00
Codex Review Integration
5cadc26fce fix(vendors): keep empty states in viewport 2026-09-14 13:31:06 -03:00
Codex Review Integration
886a9f522a test(vendors): complete company visual baselines 2026-09-14 13:23:06 -03:00
Codex Review Integration
26d50f90b6 test(vendors): align company directory visuals 2026-09-14 13:19:50 -03:00
Codex Review Integration
472d7f734b chore(vendors): trigger SH-281 review checks 2026-09-14 13:14:07 -03:00
Codex Review Integration
2eb707a522 chore(vendors): stack SH-281 on SH-279 2026-09-14 13:10:33 -03:00
Codex Review Integration
1c90aa60f3 feat(vendors): group directory rows by company 2026-09-14 13:07:00 -03:00
Codex Review Integration
7eb3308a06 refactor(vendors): satisfy row maintainability gate 2026-09-14 13:04:35 -03:00
Codex Review Integration
0b4dafc868 fix(vendors): add accessible trade overflow tooltip 2026-09-14 12:36:31 -03:00
Codex Review Integration
c587122b16 fix(vendors): add accessible trade overflow tooltip 2026-09-14 12:26:05 -03:00
103 changed files with 3102 additions and 101 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 189 KiB

View file

@ -29,6 +29,8 @@ const activeVendors = [
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
AreaId: 1 as number | null,
AreaName: "East",
},
{
Id: 2,
@ -47,9 +49,16 @@ const activeVendors = [
Notes: "",
TotalJobs: 18,
IsActive: true,
AreaId: null as number | null,
AreaName: "",
},
];
const SERVICE_AREAS = [
{ id: 2, name: "Central" },
{ id: 1, name: "East" },
];
const inactiveVendors = [
{
...activeVendors[0],
@ -135,6 +144,7 @@ async function mockVendorApi(
googleMapsUrl: vendor.GoogleMapsUrl,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
areas: SERVICE_AREAS,
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
@ -160,13 +170,20 @@ async function mockVendorApi(
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const areas = [...url.searchParams.entries()]
.filter(([key]) => key.startsWith("areas["))
.map(([, value]) => value);
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
(!company || vendor.CompanyName === company) &&
(areas.length === 0 ||
areas.some((area) =>
area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area,
)),
);
const isCount = url.searchParams.get("pageSize") === "1";
const responseRows = filtered.map((vendor) => {
@ -244,6 +261,8 @@ async function mockVendorApi(
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
technicians: [
...vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
@ -307,6 +326,8 @@ async function mockVendorApi(
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
technicians: vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
.map((vendor) => ({
@ -410,6 +431,7 @@ test.describe("Vendor directory prototype parity", () => {
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
@ -461,6 +483,7 @@ test.describe("Vendor directory prototype parity", () => {
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
@ -474,7 +497,7 @@ test.describe("Vendor directory prototype parity", () => {
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
expect((await page.locator("table").boundingBox())?.width).toBe(2050);
expect((await page.locator("table").boundingBox())?.width).toBe(2200);
expect(
(await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox())
?.height,
@ -540,8 +563,8 @@ test.describe("Vendor directory prototype parity", () => {
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
{ cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email },
{ cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address },
{ cellIndex: 7, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
{ cellIndex: 8, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
{ cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
{ cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
];
for (const expected of fields) {
@ -675,7 +698,8 @@ test.describe("Vendor directory prototype parity", () => {
"1 Market St",
);
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
await expect(page.getByLabel("State (required)")).toHaveValue("MO");
await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)");
await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
@ -748,6 +772,55 @@ test.describe("Vendor directory prototype parity", () => {
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
test("filters by Service Area and lets an admin assign one", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
const gatewayRow = page.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" });
await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East");
await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned");
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByPlaceholder("All areas").click();
await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]);
await page.getByRole("option", { name: "East" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(
page.getByRole("button", { name: "Remove filter Service Area: East" }),
).toBeVisible();
await expect(gatewayRow).toBeVisible();
await expect(metroRow).toHaveCount(0);
expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe(
true,
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByRole("button", { name: "Clear all" }).last().click();
await page.getByPlaceholder("All areas").click();
await page.getByRole("option", { name: "Unassigned" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Service Area: Unassigned")).toBeVisible();
await expect(metroRow).toBeVisible();
await expect(gatewayRow).toHaveCount(0);
await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" });
await expect(areaField).toBeEnabled();
await areaField.click();
await page.getByRole("option", { name: "Central" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.areaId).toBe(2);
expect(state.updatedBody?.state).toBe("MO");
});
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
@ -791,11 +864,14 @@ test.describe("Vendor directory prototype parity", () => {
// leave those as the only things it is exercising.
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
await page.getByLabel("City (required)").fill("St. Louis");
await page.getByLabel("State (required)").fill("MO");
await page.getByLabel("State (required)").fill("Missouri");
await page.getByRole("option", { name: "Missouri (MO)" }).click();
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
expect(state.createdBody?.state).toBe("MO");
expect(state.createdBody?.areaId).toBeNull();
});
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {

View file

@ -0,0 +1,121 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-number-sh-placeholder-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function hideQueryDevtools(page: Page) {
await page.addStyleTag({
content:
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
});
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
for (const node of nodes) {
node.remove();
}
});
}
const REAL_ROW = {
id: 1000,
woNumber: "12345678901",
workOrderType: 2,
siteCode: "DAL1",
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
};
async function mockBoard(page: Page, patches: unknown[]) {
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const url = request.url();
if (request.method() !== "GET") {
patches.push({ url, body: request.postDataJSON() });
await fulfillJson(route, {}, 500);
return;
}
if (url.includes("workorders/board/search")) {
await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 });
return;
}
if (url.includes("workorders/board")) {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [REAL_ROW],
});
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
test.describe("inline WO number SH placeholder", () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
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);
});
test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({
page,
}) => {
const mutations: unknown[] = [];
await mockBoard(page, mutations);
await page.goto("/workorders");
await hideQueryDevtools(page);
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("12345678901").first()).toBeVisible();
await page
.getByRole("button", { name: /Add WO/ })
.first()
.click();
await page
.getByText(/^Insert WO #/)
.first()
.click();
const draftInput = page.getByLabel("Work order number");
await draftInput.fill("SH00001");
await draftInput.press("Enter");
await expect(draftInput).toHaveCount(0);
await expect(page.getByText("SH00001", { exact: true })).toBeVisible();
await expect(page.getByText(/must be 11 digits/)).toHaveCount(0);
await page.getByText("12345678901", { exact: true }).first().click();
const savedInput = page.getByLabel("Work order number");
await savedInput.fill("SH00009");
await savedInput.press("Enter");
await expect(
page.getByText("Cannot replace an APM work order number with an internal number"),
).toBeVisible();
await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible();
expect(mutations).toEqual([]);
});
});

View file

@ -204,6 +204,12 @@ async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error"
});
}
async function expectWorkOrderPageReady(page: Page) {
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 90_000,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
@ -217,15 +223,18 @@ async function expectStableScreenshot(page: Page, name: string) {
}
test.describe("Work Orders deterministic pixel regression", () => {
test.setTimeout(120_000);
test("list", async ({ page }) => {
await openWorkOrderPage(page);
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
await expect(page.getByText("WO-501").first()).toBeVisible();
await expectStableScreenshot(page, "wo-list.png");
});
test("filters", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await page.getByRole("button", { name: "Advanced Filters" }).click();
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
await expectStableScreenshot(page, "wo-filters.png");
@ -233,6 +242,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("new", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await expectStableScreenshot(page, "wo-new.png");
@ -240,6 +250,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("detail", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
const row = page.locator("#wo-row-1");
await row.hover();
await row.getByRole("button", { name: "View details" }).click();
@ -249,7 +260,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("empty", async ({ page }) => {
await openWorkOrderPage(page, "empty");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
await page.getByLabel("Search work orders").fill("zz");
await expect(page.getByText("No work orders match your search")).toBeVisible();
await expectStableScreenshot(page, "wo-empty.png");
@ -257,7 +268,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("error", async ({ page }) => {
await openWorkOrderPage(page, "error");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
const alert = page.getByRole("main").getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText(/server error/i);
@ -267,6 +278,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await expect(page.getByText("WO-501").first()).toBeVisible();
await expectStableScreenshot(page, "wo-mobile.png");

View file

@ -77,6 +77,10 @@ export const API_PATHS = {
deleteEmployees: "Employee/DeleteEmployees",
dropdown: "Employee/Dropdown",
},
teamMember: {
getList: "User",
create: "team-members",
},
contact: {
getList: "Contact/GetContactList",
getContacts: "Contact/GetContacts",
@ -155,6 +159,9 @@ export const API_PATHS = {
update: "DropdownOptions/Update",
delete: "DropdownOptions",
},
services: {
list: "services",
},
user: {
list: "User",
},

View file

@ -0,0 +1,473 @@
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { AREA_ZONES } from "@/data/areas";
import { useCreateTeamMember } from "@/domain/team-members/use-cases/use-create-team-member";
import type {
CreateTeamMemberInput,
TeamMemberRole,
} from "@/domain/team-members/types/team-member";
const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"];
const COLORS = [
"#F59E0B",
"#0D9488",
"#6366F1",
"#F43F5E",
"#7C3AED",
"#0EA5E9",
"#16A34A",
"#EA580C",
"#0891B2",
"#C026D3",
] as const;
const PERMISSION_GROUPS = [
{
label: "Team Members",
keys: ["manageTeamMembers", "changeTeamMemberRole"],
},
{
label: "Vendors",
keys: ["createVendors", "editVendors", "deactivateVendors"],
},
{
label: "Sites",
keys: ["createSites", "editSites", "deleteSites"],
},
{
label: "Services",
keys: ["createServices", "editServices", "deactivateServices"],
},
{
label: "Completion Document Templates",
keys: [
"createCompletionDocTemplates",
"editCompletionDocTemplates",
"deleteCompletionDocTemplates",
],
},
{
label: "Work Orders",
keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"],
},
{
label: "Uplifts",
keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"],
},
{
label: "Dashboard",
keys: ["viewAllDispatchersOnDashboard"],
},
] as const;
const PERMISSION_LABELS: Record<string, string> = {
manageTeamMembers: "Manage team members",
changeTeamMemberRole: "Change team member role",
createVendors: "Create vendors",
editVendors: "Edit vendors",
deactivateVendors: "Deactivate vendors",
createSites: "Create sites",
editSites: "Edit sites",
deleteSites: "Delete sites",
createServices: "Create services",
editServices: "Edit services",
deactivateServices: "Deactivate services",
createCompletionDocTemplates: "Create completion document templates",
editCompletionDocTemplates: "Edit completion document templates",
deleteCompletionDocTemplates: "Delete completion document templates",
createWorkOrders: "Create work orders",
editOthersWorkOrders: "Edit other users' work orders",
cancelWorkOrders: "Cancel work orders",
deleteWorkOrders: "Delete work orders",
requestUplifts: "Request uplifts",
autoApproveUplifts: "Automatically approve uplifts",
reviewUplifts: "Review uplifts",
viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard",
};
const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys);
const DISPATCHER_DEFAULTS = new Set([
"createVendors",
"editVendors",
"deactivateVendors",
"createSites",
"editSites",
"createWorkOrders",
"editOthersWorkOrders",
"cancelWorkOrders",
"requestUplifts",
"autoApproveUplifts",
]);
const SCHEDULER_DEFAULTS = new Set([
...Array.from(DISPATCHER_DEFAULTS).filter(
(key) => key !== "requestUplifts" && key !== "autoApproveUplifts",
),
"deleteSites",
"createServices",
"editServices",
"createCompletionDocTemplates",
"editCompletionDocTemplates",
"viewAllDispatchersOnDashboard",
]);
function defaultsForRole(role: TeamMemberRole): Set<string> {
if (role === "Admin") return new Set(ALL_PERMISSION_KEYS);
return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS);
}
function emptyForm() {
return {
name: "",
role: "Dispatcher" as TeamMemberRole,
color: "",
email: "",
phone: "",
serviceAreas: [] as string[],
permissions: defaultsForRole("Dispatcher"),
};
}
function permissionOverrides(role: TeamMemberRole, selected: Set<string>) {
const defaults = defaultsForRole(role);
return Object.fromEntries(
ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [
key,
selected.has(key) ? 1 : 2,
]),
) as Record<string, 1 | 2>;
}
type TeamMemberForm = ReturnType<typeof emptyForm>;
type FormErrors = ReturnType<typeof getFormErrors>;
function getFormErrors(form: TeamMemberForm, submitted: boolean, emailIsValid: boolean) {
return {
name: submitted && !form.name.trim() ? "Name is required." : "",
role: submitted && !form.role ? "Role is required." : "",
color: submitted && !form.color ? "Color is required." : "",
email:
submitted && !form.email.trim()
? "Email is required."
: submitted && !emailIsValid
? "Enter a valid email address."
: "",
serviceAreas:
submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0
? "Select at least one service area."
: "",
};
}
function PermissionsSection({
form,
togglePermission,
}: {
form: TeamMemberForm;
togglePermission: (key: string) => void;
}) {
return (
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
Permissions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Permissions start with the selected role's defaults. Customize them before saving.
</Typography>
{PERMISSION_GROUPS.map((group) => (
<Accordion key={group.label} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>{group.label}</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack>
{group.keys.map((key) => (
<FormControlLabel
key={key}
control={
<Checkbox
checked={form.permissions.has(key)}
onChange={() => togglePermission(key)}
/>
}
label={PERMISSION_LABELS[key]}
/>
))}
</Stack>
</AccordionDetails>
</Accordion>
))}
</Box>
);
}
function TeamMemberFormFields({
form,
setForm,
errors,
updateRole,
toggleArea,
togglePermission,
}: {
form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
errors: FormErrors;
updateRole: (role: TeamMemberRole) => void;
toggleArea: (area: string) => void;
togglePermission: (key: string) => void;
}) {
return (
<Stack spacing={2.5}>
<TextField
label="Name"
placeholder="Team member name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
error={Boolean(errors.name)}
helperText={errors.name}
required
fullWidth
/>
<FormControl fullWidth required error={Boolean(errors.role)}>
<InputLabel id="team-member-role-label">Role</InputLabel>
<Select
labelId="team-member-role-label"
label="Role"
value={form.role}
onChange={(event) => updateRole(event.target.value as TeamMemberRole)}
>
{ROLES.map((role) => (
<MenuItem key={role} value={role}>
{role}
</MenuItem>
))}
</Select>
{Boolean(errors.role) && <FormHelperText>{errors.role}</FormHelperText>}
</FormControl>
<Box>
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Color <span aria-hidden="true">*</span>
</Typography>
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
{COLORS.map((color) => (
<Button
key={color}
type="button"
aria-label={`Select color ${color}`}
aria-pressed={form.color === color}
onClick={() => setForm((current) => ({ ...current, color }))}
sx={{
minWidth: 32,
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: color,
border: form.color === color ? "3px solid" : "2px solid transparent",
borderColor: form.color === color ? "text.primary" : "transparent",
boxShadow: form.color === color ? "0 0 0 2px white" : "none",
p: 0,
"&:hover": { backgroundColor: color, opacity: 0.85 },
}}
/>
))}
</Stack>
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
</Box>
{form.role === "Dispatcher" && (
<Box>
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Service Area <span aria-hidden="true">*</span>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{form.serviceAreas.length ? form.serviceAreas.join(", ") : "Select service areas…"}
</Typography>
<Stack direction="row" useFlexGap sx={{ flexWrap: "wrap", columnGap: 1 }}>
{AREA_ZONES.map((area) => (
<FormControlLabel
key={area}
control={
<Checkbox
checked={form.serviceAreas.includes(area)}
onChange={() => toggleArea(area)}
/>
}
label={area}
/>
))}
</Stack>
{Boolean(errors.serviceAreas) && (
<FormHelperText error>{errors.serviceAreas}</FormHelperText>
)}
</Box>
)}
{form.role !== "Dispatcher" && (
<Typography variant="body2" color="text.secondary">
Not applicable for this role
</Typography>
)}
<TextField
label="Phone"
placeholder="(555) 555-0100"
value={form.phone}
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
fullWidth
/>
<TextField
label="Email"
placeholder="name@seahavenind.com"
value={form.email}
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
error={Boolean(errors.email)}
helperText={errors.email || "A valid email is required to send the invite"}
required
type="email"
fullWidth
/>
<PermissionsSection form={form} togglePermission={togglePermission} />
</Stack>
);
}
export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [form, setForm] = useState(emptyForm);
const [submitted, setSubmitted] = useState(false);
const submissionId = useRef(0);
const { error: mutationError, isPending, mutate, reset } = useCreateTeamMember();
useEffect(() => {
submissionId.current += 1;
reset();
if (open) {
setForm(emptyForm());
setSubmitted(false);
}
}, [open, reset]);
const handleClose = () => {
if (isPending) return;
submissionId.current += 1;
reset();
onClose();
};
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim());
const errors = useMemo(
() => getFormErrors(form, submitted, emailIsValid),
[emailIsValid, form, submitted],
);
const isValid =
Boolean(form.name.trim()) &&
Boolean(form.role) &&
Boolean(form.color) &&
emailIsValid &&
(form.role !== "Dispatcher" || form.serviceAreas.length > 0);
const updateRole = (role: TeamMemberRole) => {
setForm((current) => ({
...current,
role,
serviceAreas: [],
permissions: defaultsForRole(role),
}));
};
const togglePermission = (key: string) => {
setForm((current) => {
const permissions = new Set(current.permissions);
if (permissions.has(key)) permissions.delete(key);
else permissions.add(key);
return { ...current, permissions };
});
};
const toggleArea = (area: string) => {
setForm((current) => ({
...current,
serviceAreas: current.serviceAreas.includes(area)
? current.serviceAreas.filter((value) => value !== area)
: [...current.serviceAreas, area],
}));
};
const submit = () => {
setSubmitted(true);
if (!isValid) return;
const input: CreateTeamMemberInput = {
name: form.name.trim(),
role: form.role,
color: form.color,
email: form.email.trim(),
phone: form.phone.trim() || undefined,
serviceAreas: form.serviceAreas,
permissionOverrides: permissionOverrides(form.role, form.permissions),
};
const currentSubmissionId = submissionId.current;
mutate(input, {
onSuccess: () => {
if (submissionId.current !== currentSubmissionId) return;
reset();
onClose();
},
});
};
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md" scroll="paper">
<DialogTitle>Add Member</DialogTitle>
<DialogContent dividers>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Register a new team member.
</Typography>
{Boolean(mutationError) && (
<Alert severity="error" sx={{ mb: 2 }}>
{mutationError?.message}
</Alert>
)}
<TeamMemberFormFields
form={form}
setForm={setForm}
errors={errors}
updateRole={updateRole}
toggleArea={toggleArea}
togglePermission={togglePermission}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button variant="contained" onClick={submit} disabled={isPending}>
{isPending ? <CircularProgress size={20} color="inherit" /> : "Add Member"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,110 @@
import {
Avatar,
Button,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import type { TeamMemberListItem } from "@/domain/team-members/types/team-member";
const HEADINGS = ["Team Member", "Role", "Service Area", "Phone", "Email", "Color", "Status"];
function memberStatusLabel(member: TeamMemberListItem) {
return member.pendingRegistration ? "Pending registration" : member.status;
}
export function TeamMembersTable({
rows,
isLoading,
hasError,
tab,
onOpen,
}: {
rows: TeamMemberListItem[];
isLoading: boolean;
hasError: boolean;
tab: "active" | "inactive";
onOpen: (member: TeamMemberListItem) => void;
}) {
return (
<TableContainer component={Paper} variant="outlined">
<Table stickyHeader size="small" sx={{ minWidth: 900 }}>
<TableHead>
<TableRow>
{HEADINGS.map((heading) => (
<TableCell key={heading} className="font-semibold">
{heading}
</TableCell>
))}
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : hasError ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Typography sx={{ fontWeight: 600 }}>Unable to load team members</Typography>
<Typography variant="body2" color="text.secondary">
Check your connection and try again.
</Typography>
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Typography sx={{ fontWeight: 600 }}>No {tab} team members found</Typography>
<Typography variant="body2" color="text.secondary">
Try a different search, or add a new team member.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((member) => (
<TableRow
key={String(member.id)}
hover
onClick={() => onOpen(member)}
sx={{ cursor: "pointer" }}
>
<TableCell>{member.name || "—"}</TableCell>
<TableCell>{member.role || "—"}</TableCell>
<TableCell>{member.serviceArea || "—"}</TableCell>
<TableCell>{member.phone || "—"}</TableCell>
<TableCell>{member.email || "—"}</TableCell>
<TableCell>
<Avatar sx={{ width: 22, height: 22, bgcolor: member.color || "primary.main" }} />
</TableCell>
<TableCell>{memberStatusLabel(member)}</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) => {
event.stopPropagation();
onOpen(member);
}}
>
View
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,184 @@
import { useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { useTeamMembersList } from "@/domain/team-members/use-cases/use-team-members-list";
import type { TeamMemberListItem } from "@/domain/team-members/types/team-member";
import {
Alert,
Avatar,
Box,
Button,
Chip,
Drawer,
IconButton,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { TeamMembersTable } from "./_components/team-members-table";
import { AddTeamMemberDialog } from "./_components/add-team-member-dialog";
type StatusTab = "active" | "inactive";
const STATUS_LABELS: Record<StatusTab, "Active" | "Inactive"> = {
active: "Active",
inactive: "Inactive",
};
function MemberStatus({ status }: { status: TeamMemberListItem["status"] }) {
return <Chip size="small" label={status} color={status === "Active" ? "success" : "default"} />;
}
function TeamMemberDrawer({
member,
onClose,
}: {
member: TeamMemberListItem | null;
onClose: () => void;
}) {
return (
<Drawer anchor="right" open={Boolean(member)} onClose={onClose}>
<Box sx={{ width: { xs: "100vw", sm: 380 }, p: 3 }}>
<Stack
direction="row"
sx={{ alignItems: "center", justifyContent: "space-between", mb: 3 }}
>
<Typography variant="h6">Team Member</Typography>
<IconButton aria-label="Close team member details" onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
{member !== null && (
<Stack spacing={2}>
<Stack direction="row" spacing={2} sx={{ alignItems: "center" }}>
<Avatar sx={{ bgcolor: member.color || "primary.main" }}>
{member.name.slice(0, 1).toUpperCase() || "?"}
</Avatar>
<Box>
<Typography variant="h6">{member.name || "—"}</Typography>
<Typography color="text.secondary">{member.role || "—"}</Typography>
</Box>
</Stack>
<MemberStatus status={member.status} />
<Typography>
<strong>Service Area:</strong> {member.serviceArea || "—"}
</Typography>
<Typography>
<strong>Phone:</strong> {member.phone || "—"}
</Typography>
<Typography>
<strong>Email:</strong> {member.email || "—"}
</Typography>
</Stack>
)}
</Box>
</Drawer>
);
}
export default function TeamMembersListPage() {
const [search, setSearch] = useState("");
const [tab, setTab] = useState<StatusTab>("active");
const [selectedMember, setSelectedMember] = useState<TeamMemberListItem | null>(null);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const debouncedSearch = useDebounce(search, 300).trim().toLowerCase();
const { data, isLoading, error } = useTeamMembersList();
const allMembers = data?.items ?? [];
const activeCount = allMembers.filter((member) => member.status === "Active").length;
const inactiveCount = allMembers.length - activeCount;
const rows = allMembers
.filter((member) => member.status === STATUS_LABELS[tab])
.filter((member) => !debouncedSearch || member.name.toLowerCase().includes(debouncedSearch));
return (
<Box
className="flex h-full flex-col overflow-hidden"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
p: 2.5,
backgroundColor: "var(--color-bg-surface)",
borderBottom: "1px solid var(--color-border)",
}}
>
<Box>
<Typography variant="h4" component="h1">
Team Members
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
{allMembers.length} team member{allMembers.length === 1 ? "" : "s"} · {activeCount}{" "}
active
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<TextField
size="small"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search team member…"
aria-label="Search team member"
sx={{ width: 224 }}
/>
<Button variant="contained" size="small" onClick={() => setAddMemberOpen(true)}>
Add Member
</Button>
</Stack>
</Stack>
<Tabs
value={tab}
onChange={(_event, value: StatusTab) => setTab(value)}
aria-label="Team member status"
>
<Tab value="active" label={`Active ${activeCount}`} />
<Tab value="inactive" label={`Inactive ${inactiveCount}`} />
</Tabs>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load team members"}
</Alert>
)}
<Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
<TeamMembersTable
rows={rows}
isLoading={isLoading}
hasError={Boolean(error)}
tab={tab}
onOpen={setSelectedMember}
/>
</Box>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
px: 2,
py: 1,
borderTop: "1px solid var(--color-border)",
backgroundColor: "var(--color-bg-surface)",
}}
>
<Typography variant="caption" color="text.secondary">
{rows.length} {tab} team member{rows.length === 1 ? "" : "s"}
{debouncedSearch ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{activeCount} active · {inactiveCount} inactive
</Typography>
</Stack>
<TeamMemberDrawer member={selectedMember} onClose={() => setSelectedMember(null)} />
<AddTeamMemberDialog open={addMemberOpen} onClose={() => setAddMemberOpen(false)} />
</Box>
);
}

View file

@ -16,6 +16,7 @@ export interface VendorListState {
isActive: boolean;
companies: string[];
trades: string[];
areas: string[];
locations: string[];
jobBuckets: string[];
};
@ -45,6 +46,7 @@ export function useVendorListState(): VendorListState {
isActive,
companies: appliedFilters.companies,
trades: appliedFilters.trades,
areas: appliedFilters.areas,
locations: appliedFilters.locations,
jobBuckets: appliedFilters.jobBuckets,
}),

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useContext, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
import {
@ -15,6 +15,8 @@ import {
type EditedCompanyFields,
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import { useVendorRosterResolver } from "./use-vendor-roster-resolver";
import { AuthContext } from "@/providers/auth-context";
import { isAdminUser } from "@/lib/auth/user-utils";
const VENDOR_ROSTER_NO_CHANGES_MESSAGE =
"No changes to save. Enter technician details or update a company field.";
@ -28,6 +30,7 @@ const EDITABLE_COMPANY_FIELDS = [
"state",
"zip",
"notes",
"areaId",
] as const;
/**
@ -48,10 +51,21 @@ import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
import type {
VendorCompanyRoster,
VendorFacetArea,
VendorFacetCompany,
VendorRosterConflict,
} from "@/domain/vendors/types/vendor";
export interface VendorRosterAreaFieldProps {
areaOptions: VendorFacetArea[];
currentArea: VendorFacetArea | null;
canAssignArea: boolean;
}
function toCurrentArea(roster: VendorCompanyRoster | null | undefined): VendorFacetArea | null {
return roster && roster.areaId !== "" ? { id: roster.areaId, name: roster.areaName } : null;
}
function withoutBlankNewTechnicians(
values: VendorCompanyRosterFormValues,
): VendorCompanyRosterFormValues {
@ -96,6 +110,7 @@ export interface VendorRosterForm {
companies: VendorFacetCompany[];
trades: string[];
tradesLoading: boolean;
areaField: VendorRosterAreaFieldProps;
selectedCompanyId: string | number | null;
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
clearSelectedCompany: (nextName?: string) => void;
@ -116,6 +131,8 @@ export function useVendorRosterForm({
);
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
const save = useSaveVendorCompanyRoster();
// Area assignment is Admin-only; the API enforces it and the field mirrors it.
const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles);
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
const routeRoster = mode === "update" ? query.data : undefined;
@ -151,6 +168,14 @@ export function useVendorRosterForm({
clearConflict,
});
const { resetSelection } = selection;
const areaField = useMemo<VendorRosterAreaFieldProps>(
() => ({
areaOptions: facets?.areas ?? [],
currentArea: toCurrentArea(routeRoster ?? selection.selectedRoster),
canAssignArea,
}),
[canAssignArea, facets, routeRoster, selection.selectedRoster],
);
const isValid = useMemo(() => {
if (vendorCompanyRosterSchema.safeParse(watched).success) return true;
@ -238,6 +263,7 @@ export function useVendorRosterForm({
companies,
trades,
tradesLoading: facetsLoading,
areaField,
selectedCompanyId: selection.selectedCompanyId,
selectCompany: selection.selectCompany,
clearSelectedCompany: selection.clearSelectedCompany,

View file

@ -43,6 +43,7 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
// The deprecated stored Maps URL is intentionally not seeded: the form has
// no input for it, and carrying it would re-emit a legacy value on write.
notes: roster.notes,
areaId: roster.areaId,
technicians: roster.technicians.map((technician) => ({
id: technician.id,
contactName: technician.contactName,

View file

@ -77,6 +77,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
selectedCompanyId={form.selectedCompanyId}
onSelectCompany={form.selectCompany}
onClearSelectedCompany={form.clearSelectedCompany}
{...form.areaField}
/>
</Box>
</Stack>

View file

@ -155,6 +155,7 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
<Field label="State" value={roster.state} />
</Box>
</Stack>
<Field label="Service Area" value={roster.areaName || "Unassigned"} />
<AddressMapPreview address={address} />
</Stack>
);
@ -336,6 +337,7 @@ function DrawerEditor({
tradeOptions={form.trades}
tradeOptionsLoading={form.tradesLoading}
showTechnicianStatus={false}
{...form.areaField}
/>
{selectedIndex >= 0 && (
<>

View file

@ -3,6 +3,7 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import MapOutlinedIcon from "@mui/icons-material/MapOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
@ -17,13 +18,14 @@ import {
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
import {
type VendorFacetArea,
type VendorFacets,
type VendorFacetCompany,
type VendorFacetJobBucket,
type VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
import { serviceAreaFilterOptions, type VendorFilters } from "./vendor-filters";
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
@ -156,6 +158,33 @@ interface FilterProps<T> {
setDraft: DraftSetter;
}
function ServiceAreaFilter({ options, draft, setDraft }: FilterProps<VendorFacetArea>) {
return (
<FilterSection title="Service Area" icon={<MapOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.filter((option) => draft.areas.includes(option.id))}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, areas: value.map((entry) => entry.id) }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={option.name} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.areas.length === 0 ? "All areas" : ""} />
)}
/>
</FilterSection>
);
}
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
@ -245,6 +274,11 @@ export function VendorFilterOptions({
<>
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
<ServiceAreaFilter
options={serviceAreaFilterOptions(facets?.areas ?? [])}
draft={draft}
setDraft={setDraft}
/>
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
</>

View file

@ -31,7 +31,11 @@ export function VendorFiltersDrawer({
}, [open, appliedFilters]);
const activeCount =
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
draft.companies.length +
draft.trades.length +
draft.areas.length +
draft.locations.length +
draft.jobBuckets.length;
const handleClear = () => {
setDraft(emptyVendorFilters);

View file

@ -1,6 +1,10 @@
import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor";
export interface VendorFilters {
companies: string[];
trades: string[];
/** Stable Area ids or VENDOR_AREA_UNASSIGNED; OR within the facet. */
areas: string[];
locations: string[];
jobBuckets: string[];
}
@ -8,6 +12,17 @@ export interface VendorFilters {
export const emptyVendorFilters: VendorFilters = {
companies: [],
trades: [],
areas: [],
locations: [],
jobBuckets: [],
};
const UNASSIGNED_AREA_OPTION: VendorFacetArea = { id: VENDOR_AREA_UNASSIGNED, name: "Unassigned" };
/** Unassigned first, then the active Area catalogue A–Z (case-insensitive). */
export function serviceAreaFilterOptions(areas: VendorFacetArea[]): VendorFacetArea[] {
const sorted = [...areas].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
return [UNASSIGNED_AREA_OPTION, ...sorted];
}

View file

@ -30,8 +30,11 @@ import {
type RosterTechnicianValues,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
import type { VendorFacetArea, VendorFacetCompany } from "@/domain/vendors/types/vendor";
import { normalizeStateCode } from "@/data/areas";
import { VendorCompanyContactFields } from "./vendor-company-contact-fields";
import { VendorServiceAreaField } from "./vendor-service-area-field";
import { VendorStateSelectField } from "./vendor-state-select-field";
interface VendorRosterFormFieldsProps {
control: Control<VendorCompanyRosterFormValues>;
@ -43,6 +46,9 @@ interface VendorRosterFormFieldsProps {
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
onClearSelectedCompany?: (nextName?: string) => void;
showTechnicianStatus?: boolean;
areaOptions?: VendorFacetArea[];
currentArea?: VendorFacetArea | null;
canAssignArea?: boolean;
}
function CompanyNameField({
@ -136,6 +142,9 @@ function CompanyFields({
selectedCompanyId,
onSelectCompany,
onClearSelectedCompany,
areaOptions = [],
currentArea = null,
canAssignArea = false,
}: VendorRosterFormFieldsProps) {
const notes = useWatch({ control, name: "notes" }) ?? "";
const { field: streetField } = useController({ control, name: "address" });
@ -163,7 +172,7 @@ function CompanyFields({
// revalidates — a plain assignment would be discarded on save.
streetField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(parts.state);
stateField.onChange(normalizeStateCode(parts.state) ?? parts.state);
}}
label="Street Address (required)"
required
@ -183,15 +192,13 @@ function CompanyFields({
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
<TextField
{...stateField}
<VendorStateSelectField
value={stateField.value ?? ""}
label="State (required)"
placeholder="e.g., MO"
onChange={stateField.onChange}
onBlur={stateField.onBlur}
inputRef={stateField.ref}
error={Boolean(errors.state)}
helperText={errors.state?.message}
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
</Stack>
<Controller
@ -234,6 +241,12 @@ function CompanyFields({
/>
)}
/>
<VendorServiceAreaField
control={control}
areaOptions={areaOptions}
currentArea={currentArea}
canAssign={canAssignArea}
/>
</Stack>
</Box>
);

View file

@ -0,0 +1,58 @@
import { Controller, type Control } from "react-hook-form";
import { Autocomplete, TextField } from "@mui/material";
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorFacetArea } from "@/domain/vendors/types/vendor";
interface VendorServiceAreaFieldProps {
control: Control<VendorCompanyRosterFormValues>;
areaOptions: VendorFacetArea[];
currentArea?: VendorFacetArea | null;
canAssign: boolean;
}
export const SERVICE_AREA_ADMIN_ONLY_MESSAGE = "Only administrators can assign a Service Area.";
/**
* Zero or one Service Area per vendor company, chosen from the Area
* catalogue by stable id. Clearing the selection leaves the company Unassigned.
* The saved Area stays visible when it is no longer in the active catalogue.
*/
export function VendorServiceAreaField({
control,
areaOptions,
currentArea,
canAssign,
}: VendorServiceAreaFieldProps) {
const options =
currentArea && !areaOptions.some((area) => area.id === currentArea.id)
? [...areaOptions, currentArea]
: areaOptions;
return (
<Controller
control={control}
name="areaId"
render={({ field }) => (
<Autocomplete<VendorFacetArea>
options={options}
value={options.find((area) => area.id === field.value) ?? null}
onChange={(_event, option) => field.onChange(option?.id ?? "")}
onBlur={field.onBlur}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
disabled={!canAssign}
fullWidth
renderInput={(params) => (
<TextField
{...params}
label="Service Area (optional)"
placeholder="Unassigned"
inputRef={field.ref}
helperText={canAssign ? undefined : SERVICE_AREA_ADMIN_ONLY_MESSAGE}
/>
)}
/>
)}
/>
);
}

View file

@ -0,0 +1,57 @@
import type { Ref } from "react";
import { Autocomplete, TextField } from "@mui/material";
import {
US_STATE_CODES,
formatStateOptionLabel,
normalizeStateCode,
type StateCode,
} from "@/data/areas";
interface VendorStateSelectFieldProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
inputRef?: Ref<HTMLInputElement>;
error?: boolean;
helperText?: string;
}
/**
* Closed, searchable list of the 50 US states for the vendor company.
* Legacy full names are shown as their canonical code. Service Area is chosen
* independently, so no Area is derived from the selected State here.
*/
export function VendorStateSelectField({
value,
onChange,
onBlur,
inputRef,
error,
helperText,
}: VendorStateSelectFieldProps) {
const selected: StateCode | null = normalizeStateCode(value);
return (
<Autocomplete<StateCode>
options={US_STATE_CODES}
value={selected}
onChange={(_event, option) => onChange(option ?? "")}
onBlur={onBlur}
getOptionLabel={formatStateOptionLabel}
fullWidth
renderInput={(params) => (
<TextField
{...params}
label="State (required)"
placeholder="Select a state"
inputRef={inputRef}
error={error}
helperText={helperText}
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required: true },
}}
/>
)}
/>
);
}

View file

@ -155,6 +155,7 @@ function CompanyCells({ row }: { row: VendorListItem }) {
<CellText value={row.address} testId="vendor-text-street-address" focusable />
<CellText value={row.city} testId="vendor-text-city" focusable />
<CellText value={row.state} testId="vendor-text-state" focusable />
<CellText value={row.areaName || "Unassigned"} testId="vendor-text-service-area" focusable />
</>
);
}

View file

@ -21,6 +21,7 @@ const TABLE_COLUMNS = [
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
@ -66,7 +67,7 @@ export function VendorsTable({
size="small"
stickyHeader
sx={{
minWidth: 2050,
minWidth: 2200,
tableLayout: "fixed",
"& .MuiTableCell-head": {
backgroundColor: "var(--color-bg-muted)",
@ -94,7 +95,7 @@ export function VendorsTable({
}}
>
<colgroup>
{[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => (
{[190, 160, 220, 220, 140, 100, 150, 190, 220, 170, 110, 120, 130].map((width, index) => (
<col key={`${width}-${index}`} style={{ width }} />
))}
</colgroup>

View file

@ -13,7 +13,7 @@ import { VendorsTable } from "./_components/vendors-table";
import { useVendorDeactivation } from "./_components/use-vendor-deactivation";
import { useVendorListState } from "./_components/use-vendor-list-state";
import type { VendorFilters } from "./_components/vendor-filters";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
import { VENDOR_AREA_UNASSIGNED, type VendorListItem } from "@/domain/vendors/types/vendor";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list";
@ -91,6 +91,7 @@ function VendorStatusTabs({
function describeChips(
filters: VendorFilters,
jobBucketLabels: Map<string, string>,
areaLabels: Map<string, string>,
): AppliedChip[] {
const chips: AppliedChip[] = [];
filters.companies.forEach((value) =>
@ -99,6 +100,13 @@ function describeChips(
filters.trades.forEach((value) =>
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
);
filters.areas.forEach((value) =>
chips.push({
category: "areas",
value,
label: `Service Area: ${areaLabels.get(value) ?? value}`,
}),
);
filters.locations.forEach((value) =>
chips.push({ category: "locations", value, label: `Location: ${value}` }),
);
@ -146,7 +154,15 @@ export default function VendorsListPage() {
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
[facets],
);
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
const areaLabels = useMemo(
() =>
new Map([
[VENDOR_AREA_UNASSIGNED, "Unassigned"],
...(facets?.areas ?? []).map((area): [string, string] => [area.id, area.name]),
]),
[facets],
);
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels, areaLabels);
return (
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">

View file

@ -8,13 +8,29 @@ type SlideOverCompletionActionsProps = {
docStatus: CompletionDocStatus;
rowVersion?: string | number | null;
completionPending: boolean;
/** Completion documents require a selected primary service. */
hasService: boolean;
hasUploadHandler: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onPdfSelected: (files: FileList | null) => void;
onOpenCompletionDoc: () => void;
};
function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
function DocStatusButtonContent({
docStatus,
hasService,
}: {
docStatus: CompletionDocStatus;
hasService: boolean;
}) {
if (!hasService) {
return (
<>
<FileText size={13} />
Select service
</>
);
}
if (docStatus === "Yes") {
return (
<>
@ -43,6 +59,7 @@ export function SlideOverCompletionActions({
docStatus,
rowVersion,
completionPending,
hasService,
hasUploadHandler,
pdfInputRef,
onPdfSelected,
@ -83,10 +100,17 @@ export function SlideOverCompletionActions({
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
disabled={!hasService}
title={hasService ? undefined : "Select a service before opening the completion document"}
onClick={onOpenCompletionDoc}
>
<DocStatusButtonContent docStatus={docStatus} />
<DocStatusButtonContent docStatus={docStatus} hasService={hasService} />
</Button>
{!hasService && (
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Select a service before opening the completion document.
</Text>
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media";
import {
formatMediaSizeLimit,
@ -126,6 +127,7 @@ export function SlideOverCompletionTab({
docStatus={docStatus}
rowVersion={row.rowVersion}
completionPending={completionPending}
hasService={hasCompletionDocService(row.pm)}
hasUploadHandler={Boolean(onUploadCompletionPdf)}
pdfInputRef={pdfInputRef}
onPdfSelected={handlePdfSelected}

View file

@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput(
scheduledOn: draft.scheduledOn,
dueDate: draft.dueDate,
pm: draft.pm,
serviceId: null,
extraServices: draft.extraServices ?? [],
avetaRequired: Boolean(draft.avetaRequired),
pmNote: draft.serviceNotes ?? "",

View file

@ -3,6 +3,12 @@ import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import {
WO_NUMBER_DOWNGRADE_MESSAGE,
WO_NUMBER_FORMAT_MESSAGE,
isValidBoardWoNumber,
isWoNumberDowngrade,
} from "@/domain/work-orders/utils/wo-number-input";
import { EditableWONumberInput } from "./editable-wo-number-input";
import { HighlightText } from "./highlight-text";
import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills";
@ -26,6 +32,9 @@ type EditableWONumberCellProps = {
requiredPlaceholder?: string;
};
/** Inline-create rows pass this id; their number is draft state, never a saved WO number. */
const UNSAVED_DRAFT_ID = "draft";
async function resolveDuplicateWoNumber(
draft: string,
allOrders: WorkOrderTableRow[],
@ -88,8 +97,14 @@ export function EditableWONumberCell({
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
if (!isValidBoardWoNumber(draft)) {
toast(WO_NUMBER_FORMAT_MESSAGE);
return;
}
if (String(currentId) !== UNSAVED_DRAFT_ID && isWoNumberDowngrade(value, draft)) {
toast.error(WO_NUMBER_DOWNGRADE_MESSAGE);
setDraft(value);
setEditing(false);
return;
}
setIsChecking(true);
@ -122,14 +137,14 @@ export function EditableWONumberCell({
const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) {
if (draft !== value && isValidBoardWoNumber(draft)) {
commit();
return;
}
setEditing(false);
};
const invalid = editing && draft.length > 0 && draft.length !== 11;
const invalid = editing && draft.length > 0 && !isValidBoardWoNumber(draft);
if (locked) {
return (

View file

@ -1,3 +1,5 @@
import { sanitizeBoardWoNumberInput } from "@/domain/work-orders/utils/wo-number-input";
type EditableWONumberInputProps = {
draft: string;
invalid: boolean;
@ -21,9 +23,8 @@ export function EditableWONumberInput({
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(event) => onChange(event.target.value.replace(/\D/g, "").slice(0, 11))}
onChange={(event) => onChange(sanitizeBoardWoNumberInput(event.target.value))}
onFocus={(event) => event.target.select()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
@ -36,7 +37,7 @@ export function EditableWONumberInput({
}
}}
onBlur={onBlur ?? onCommit}
placeholder="12345678901"
placeholder="e.g. 12345678901 or SH00001"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"

View file

@ -39,7 +39,7 @@ export function PmTypePickerPopover({
autoFocus
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search PM type…"
placeholder="Search service…"
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>

View file

@ -11,9 +11,10 @@ export const WO_TABLE_COLS = [
{ key: "vendor", label: "VENDOR", w: 184 },
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
// Completed Date gates the Completion Document, so it sits between Status and Comp Doc.
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "uplift", label: "UPLIFT", w: 108 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;

View file

@ -105,6 +105,17 @@ export function WoTableRowServiceCells({
onRequestComplete={() => handlers.onRequestComplete?.(row)}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<CompDocCell
status={row.docStatus}
@ -126,17 +137,6 @@ export function WoTableRowServiceCells({
}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
/>
</td>
</>
);
}

View file

@ -82,7 +82,10 @@ export function NewWoWizard({
locationDetail={wizard.locationDetail}
locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
services={wizard.services.map((service) => ({
id: service.id,
value: service.name,
}))}
showErrors={wizard.showErrors}
svcOpen={wizard.svcOpen}
onSvcOpenChange={wizard.setSvcOpen}

View file

@ -2,8 +2,6 @@ import { useEffect } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
@ -16,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 { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
type UseNewWoWizardControllerArgs = {
open: boolean;
@ -32,7 +31,7 @@ export function useNewWoWizardController({
const draftState = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const { data: registryServices = [] } = useServicesForWorkOrderType(draftState.draft.type);
const {
data: locationDetail,
isLoading: locationDetailLoading,
@ -78,7 +77,7 @@ export function useNewWoWizardController({
...draftState,
patchDraft,
locations,
problems,
services: registryServices,
locationDetail,
locationDetailLoading,
locationDetailError,

View file

@ -32,8 +32,10 @@ export function WizardStepLocationServiceSelect({
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);
onPatch({
pm: p,
serviceId: selected?.id ?? null,
extraServices: [],
...clearVendorIfPrimaryChanged(p),
});
@ -48,7 +50,7 @@ export function WizardStepLocationServiceSelect({
icon={
<Droplets size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search PM type…"
searchPlaceholder="Search services…"
options={services.map((s) => {
const Icon = getPmTypeIcon(s.value);
return {

View file

@ -270,9 +270,23 @@ export function WizardStepTypeSchedule({
<WoTypePicker
selected={draft.type}
onSelect={(t) => {
const typeChanged = t !== draft.type;
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
// A registry service is scoped to its work-order type, so a service picked
// under the previous type fails backend ResolveServiceAsync (SH-187/#131).
// Clear the service (and its vendor, filtered by the service) on a type change.
...(typeChanged
? {
pm: "",
serviceId: null,
extraServices: [],
vendorId: "",
vendorName: "",
techPhone: "",
}
: {}),
});
}}
/>

View file

@ -10,6 +10,7 @@ import {
Bell,
BarChart3,
Star,
Users,
} from "lucide-react";
export type NavItemDef = {
@ -58,6 +59,10 @@ export const navGroups: NavGroup[] = [
{ label: "Sites", icon: MapPin, path: "/locations" },
],
},
{
title: "TEAM",
items: [{ label: "Team Members", icon: Users, path: "/team-members" }],
},
// {
// title: "INTERNAL",
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],

View file

@ -0,0 +1,30 @@
import { apiGet } 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 { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
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,
};
export const servicesApi = {
getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true },
});
return mapServices(handleApiResponse(data));
},
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
if (workOrderType === "Overdue") return [];
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
});
return mapServices(handleApiResponse(data));
},
};

View file

@ -0,0 +1,71 @@
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
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 {
for (const key of keys) {
const value = item[key];
if (typeof value === "string") return value.trim();
}
return "";
}
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = item[key];
if (typeof value === "boolean") return value;
}
return false;
}
function mapWorkOrderType(value: unknown): WOType | 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;
}
export function mapService(raw: unknown): Service {
const item = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
return {
id: readId(item),
name: readString(item, "name", "Name"),
trade: readString(item, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
item,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
isActive: readBool(item, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
return mapped ? [mapped] : [];
})
: [],
};
}
export function mapServices(raw: unknown): Service[] {
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
}

View file

@ -0,0 +1,11 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export interface Service {
id: string | number;
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
isActive: boolean;
supportedWorkOrderTypes: WOType[];
}

View file

@ -0,0 +1,25 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { servicesApi } from "@/domain/services/api/services-api";
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServicesForWorkOrderType(
workOrderType: WOType | "",
): UseQueryResult<Service[], Error> {
const effectiveType = workOrderType || "PM";
return useQuery({
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
enabled: effectiveType !== "Overdue",
staleTime: 60 * 1000,
});
}
export function useActiveServices(): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.active(),
queryFn: () => servicesApi.getActive(),
staleTime: 60 * 1000,
});
}

View file

@ -0,0 +1,22 @@
import { apiGet, apiPost } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapTeamMemberListItems } from "@/domain/team-members/mappers/team-member-mapper";
import type {
CreateTeamMemberInput,
TeamMemberCreated,
TeamMemberListResult,
} from "@/domain/team-members/types/team-member";
export const teamMembersApi = {
getList: async (): Promise<TeamMemberListResult> => {
const data = await apiGet<unknown>(API_PATHS.teamMember.getList);
const items = handleApiResponse<unknown[]>(data);
return { items: mapTeamMemberListItems(Array.isArray(items) ? items : []) };
},
create: async (input: CreateTeamMemberInput): Promise<TeamMemberCreated> => {
const data = await apiPost<unknown>(API_PATHS.teamMember.create, input);
return handleApiResponse<TeamMemberCreated>(data);
},
};

View file

@ -0,0 +1,46 @@
import type { TeamMemberListItem } from "@/domain/team-members/types/team-member";
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
}
return "";
}
function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "string") return value.toLowerCase() === "true";
}
return false;
}
export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem {
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const id = record.id ?? record.Id;
const status =
readString(record, "status", "Status").toLowerCase() === "inactive" ||
readBoolean(record, "isDeleted", "IsDeleted")
? "Inactive"
: "Active";
return {
id: typeof id === "string" || typeof id === "number" ? id : "",
name: readString(record, "name", "Name"),
role: readString(record, "role", "Role", "roleName", "RoleName"),
serviceArea: readString(record, "serviceArea", "ServiceArea", "locations", "Locations"),
phone: readString(record, "phone", "Phone"),
email: readString(record, "email", "Email"),
color: readString(record, "color", "Color"),
status,
isAccountOwner: readBoolean(record, "isAccountOwner", "IsAccountOwner"),
pendingRegistration: readBoolean(record, "pendingRegistration", "PendingRegistration"),
};
}
export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[] {
return rawItems.map(mapTeamMemberListItem);
}

View file

@ -0,0 +1,39 @@
export interface TeamMemberListItem {
id: string | number;
name: string;
role: string;
serviceArea: string;
phone: string;
email: string;
color: string;
status: "Active" | "Inactive";
isAccountOwner: boolean;
pendingRegistration: boolean;
}
export type TeamMemberRole = "Dispatcher" | "Scheduler" | "Admin";
export interface CreateTeamMemberInput {
name: string;
role: TeamMemberRole;
color: string;
email: string;
phone?: string;
serviceAreas: string[];
permissionOverrides: Record<string, 1 | 2>;
}
export interface TeamMemberCreated {
id: string;
name: string;
role: TeamMemberRole;
color: string;
email: string;
phone: string;
serviceAreas: string[];
pendingRegistration: boolean;
}
export interface TeamMemberListResult {
items: TeamMemberListItem[];
}

View file

@ -0,0 +1,27 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
import type {
CreateTeamMemberInput,
TeamMemberCreated,
} from "@/domain/team-members/types/team-member";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateTeamMember(): UseMutationResult<
TeamMemberCreated,
Error,
CreateTeamMemberInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: teamMembersApi.create,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all });
toast.success("Team member created with pending registration.");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create team member");
},
});
}

View file

@ -0,0 +1,11 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
import type { TeamMemberListResult } from "@/domain/team-members/types/team-member";
import { queryKeys } from "@/infra/query-key/query-key";
export function useTeamMembersList(): UseQueryResult<TeamMemberListResult, Error> {
return useQuery({
queryKey: queryKeys.teamMembers.list(),
queryFn: teamMembersApi.getList,
});
}

View file

@ -56,6 +56,7 @@ function buildListParams(params: VendorsListParams): Record<string, string | num
pushRepeated(searchParams, "trades", params.trades);
pushRepeated(searchParams, "locations", params.locations);
pushRepeated(searchParams, "jobBuckets", params.jobBuckets);
pushRepeated(searchParams, "areas", params.areas);
return searchParams;
}

View file

@ -2,6 +2,7 @@ import type {
Vendor,
VendorDeactivationImpact,
VendorDirectoryTechnician,
VendorFacetArea,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
@ -126,6 +127,8 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
areaId: readString(item, "areaId", "AreaId"),
areaName: readString(item, "areaName", "AreaName"),
};
const rawTechnicians = item.technicians ?? item.Technicians;
const technicians = Array.isArray(rawTechnicians)
@ -290,6 +293,14 @@ function mapFacetLocation(raw: unknown): VendorFacetLocation {
};
}
function mapFacetArea(raw: unknown): VendorFacetArea {
const item = asRecord(raw);
return {
id: readString(item, "id", "Id"),
name: readString(item, "name", "Name"),
};
}
function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket {
const item = asRecord(raw);
return {
@ -304,6 +315,7 @@ export function mapVendorFacets(raw: unknown): VendorFacets {
const tradesRaw = item.trades ?? item.Trades;
const locationsRaw = item.locations ?? item.Locations;
const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets;
const areasRaw = item.areas ?? item.Areas;
return {
companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [],
@ -315,6 +327,9 @@ export function mapVendorFacets(raw: unknown): VendorFacets {
})
.filter(Boolean)
: [],
areas: Array.isArray(areasRaw)
? areasRaw.map(mapFacetArea).filter((area) => area.id !== "" && area.name !== "")
: [],
locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [],
jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [],
};

View file

@ -92,10 +92,18 @@ export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster {
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
areaId: readString(item, "areaId", "AreaId"),
areaName: readString(item, "areaName", "AreaName"),
technicians: mapRosterTechnicians(item.technicians ?? item.Technicians),
};
}
/** Form Area ids are strings; the API takes a positive integer id or null (Unassigned). */
export function toBackendAreaId(value: unknown): number | null {
const parsed = Number(typeof value === "string" ? value.trim() : value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
export function mapRosterTechnicianToBackend(technician: unknown): Record<string, unknown> {
const item = asRecord(technician);
const payload: Record<string, unknown> = {
@ -130,6 +138,11 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
? techniciansRaw.map(mapRosterTechnicianToBackend)
: [],
};
// The form always round-trips its Area (id or null = Unassigned). Callers
// without an Area value omit it, which the API treats as "leave unchanged".
if ("areaId" in item || "AreaId" in item) {
payload.areaId = toBackendAreaId(item.areaId ?? item.AreaId);
}
const rowVersion = readString(item, "rowVersion", "RowVersion");
if (rowVersion) payload.rowVersion = rowVersion;
return payload;
@ -157,9 +170,12 @@ export function mapVendorRosterAdditivePatchToBackend(
// SH-250: match the reconcile write path, which canonicalizes companyPhone.
// Copying companyFields verbatim let additive patches send a different phone
// shape than PUT for the same user input.
const companyFields = { ...patch.companyFields };
const companyFields: Record<string, unknown> = { ...patch.companyFields };
if (companyFields.companyPhone !== undefined)
companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone);
// An edited Area is sent as an id or an explicit null (Unassigned).
if (companyFields.areaId !== undefined)
companyFields.areaId = toBackendAreaId(companyFields.areaId);
payload.companyFields = companyFields;
}
return payload;

View file

@ -54,6 +54,8 @@ const baseCompanyFields = {
notes: z
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
// Optional stable Area id; "" means Unassigned. Admin-only to change.
areaId: z.string(),
technicians: z.array(rosterTechnicianSchema),
};
@ -107,5 +109,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
state: "",
zip: "",
notes: "",
areaId: "",
technicians: [],
};

View file

@ -30,6 +30,10 @@ export interface VendorListItem {
tradeSpecialties: string;
isActive: boolean;
preferredContact: VendorPreferredContact;
/** Stable Area id as a string; "" when the company is Unassigned. */
areaId: string;
/** Area display name; "" when the company is Unassigned. */
areaName: string;
/** Present on company-grouped directory responses; optional for legacy clients/fixtures. */
technicians?: VendorDirectoryTechnician[];
}
@ -63,6 +67,8 @@ export interface VendorsListParams {
trades?: string[];
locations?: string[];
jobBuckets?: string[];
/** Stable Area ids or the "__unassigned__" sentinel. */
areas?: string[];
}
export interface VendorsListResult {
@ -95,9 +101,19 @@ export interface VendorFacetJobBucket {
label: string;
}
/** Active Area catalogue option. `id` is the stable areas[n] filter value. */
export interface VendorFacetArea {
id: string;
name: string;
}
/** Wire value that selects companies without an Area. */
export const VENDOR_AREA_UNASSIGNED = "__unassigned__";
export interface VendorFacets {
companies: VendorFacetCompany[];
trades: string[];
areas: VendorFacetArea[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
}
@ -147,6 +163,9 @@ export interface VendorCompanyRoster {
zip: string;
googleMapsUrl: string;
notes: string;
/** Stable Area id as a string; "" when the company is Unassigned. */
areaId: string;
areaName: string;
technicians: VendorRosterTechnician[];
}

View file

@ -38,6 +38,7 @@ const COMPANY_FIELDS = [
"state",
"zip",
"notes",
"areaId",
] as const;
const TECHNICIAN_FIELDS = [

View file

@ -0,0 +1,45 @@
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
import { mapHttpStatusToMessage } from "@/api/api-error";
import {
DISPATCH_REQUIRED_MESSAGE,
DUPLICATE_WO_NUMBER_MESSAGE,
} from "@/domain/work-orders/errors/board-patch-errors";
export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again.";
/** Statuses whose mapped copy is fixed product text, never the response body. */
const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]);
function readErrorCode(data: unknown): string | undefined {
if (typeof data !== "object" || data === null) {
return undefined;
}
const record = data as Record<string, unknown>;
const code = record.code ?? record.Code;
return typeof code === "string" ? code : undefined;
}
/**
* why: board create returns backend validation text ("siteCode is required.",
* "vendorId is required when setting appointment fields.") that must never reach the
* screen. Unknown failures fall back to generic copy, not to `error.message`.
*/
export function boardCreateErrorMessage(error: unknown): string {
if (isHTTPError(error)) {
const code = readErrorCode(error.data);
if (code === "DispatchRequired") {
return DISPATCH_REQUIRED_MESSAGE;
}
if (code === "DuplicateWoNumber") {
return DUPLICATE_WO_NUMBER_MESSAGE;
}
const status = error.response.status;
return FIXED_COPY_STATUSES.has(status)
? mapHttpStatusToMessage(status)
: BOARD_CREATE_FAILED_MESSAGE;
}
if (isNetworkError(error) || isTimeoutError(error)) {
return error.message;
}
return BOARD_CREATE_FAILED_MESSAGE;
}

View file

@ -1,3 +1,5 @@
import { isNetworkError, isTimeoutError } from "ky";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export class BoardPatchConflictError extends Error {
@ -22,10 +24,66 @@ export class BoardPatchValidationError extends Error {
}
}
/** why: backend field names must never be exposed through user-facing board toasts. */
export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving.";
export const BOARD_PATCH_FAILED_MESSAGE = "Unable to save the work order. Please try again.";
export const NO_PERSISTABLE_FIELDS_MESSAGE =
"These changes cannot be saved on the board. Refresh and edit a supported field.";
export const DUPLICATE_WO_NUMBER_MESSAGE =
"This work order number is already assigned to another work order.";
const KNOWN_VALIDATION_COPY: ReadonlyMap<string, string> = new Map([
["DispatchRequired", DISPATCH_REQUIRED_MESSAGE],
["NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE],
// why: editing a WO number to one that already exists returns 422 DuplicateWoNumber;
// show the same product copy as create, not the generic save-failed fallback.
["DuplicateWoNumber", DUPLICATE_WO_NUMBER_MESSAGE],
]);
/**
* why: board PATCH validation text ("vendorId is required…", object ids, backend codes)
* must never reach the screen. Unknown codes fall back to generic copy, not `error.message`.
*/
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
if (error.code === "DispatchRequired") {
return "Select a vendor company before saving.";
}
return error.message;
return KNOWN_VALIDATION_COPY.get(error.code) ?? BOARD_PATCH_FAILED_MESSAGE;
}
export const BOARD_PATCH_CONFLICT_MESSAGE =
"This work order was changed by someone else. Refresh to see the latest before saving again.";
export const PARTIAL_SAVE_PREFIX = "Some changes were saved, but the rest were not.";
/** Statuses whose mapped copy is fixed product text, never the response body. */
const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]);
/** why: any board edit failure toast must use product copy, never backend text or field keys. */
export function boardPatchErrorMessage(error: unknown): string {
if (error instanceof BoardPatchConflictError) {
return BOARD_PATCH_CONFLICT_MESSAGE;
}
if (error instanceof BoardPatchValidationError) {
return boardPatchValidationMessage(error);
}
if (error instanceof ApiError && FIXED_COPY_STATUSES.has(error.status)) {
return mapHttpStatusToMessage(error.status);
}
if (isNetworkError(error) || isTimeoutError(error)) {
return error.message;
}
return BOARD_PATCH_FAILED_MESSAGE;
}
/** Thrown when a multi-field board edit fails after some fields already persisted. */
export class BoardPatchPartialSaveError extends Error {
readonly partialRow: WorkOrderTableRow;
readonly failure: unknown;
constructor(partialRow: WorkOrderTableRow, failure: unknown) {
super(`${PARTIAL_SAVE_PREFIX} ${boardPatchErrorMessage(failure)}`);
this.name = "BoardPatchPartialSaveError";
this.partialRow = partialRow;
this.failure = failure;
}
}

View file

@ -19,6 +19,10 @@ export function assignWizardServicePocFields(
payload.primaryService = primaryService;
}
if (draft.serviceId != null) {
payload.serviceId = draft.serviceId;
}
const serviceNotes = trimOrUndefined(draft.pmNote);
if (serviceNotes) {

View file

@ -400,6 +400,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
locationId:
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"),
serviceId: readNumber(item, "serviceId", "ServiceId"),
serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"),
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",

View file

@ -105,6 +105,8 @@ export interface WorkOrderBoardCreatePayload {
trade?: string;
/** Primary service label; persisted as board `pm`. */
primaryService?: string;
/** Registry service identity; the backend validates active/type-compatible selections. */
serviceId?: string | number;
/** Services beyond primary; order preserved. */
extraServices?: string[];
/** Issue detail from Service & Notes (`pmNote` in draft). */
@ -171,6 +173,7 @@ export interface WorkOrderBoardRowApi {
originalDate?: string | null;
originalWeek?: string | null;
workOrderType?: number | null;
serviceId?: number | null;
isPastDue?: boolean;
siteCode?: string | null;
locationName?: string | null;

View file

@ -60,6 +60,7 @@ export interface WorkOrderTableRow {
location: string;
locationId: string | number;
pm: string;
serviceId?: string | number | null;
/** Issue detail from Service & Notes. */
serviceNotes: string;
/** Additional services beyond primary `pm`. */

View file

@ -37,6 +37,8 @@ export interface WorkOrderWizardDraft {
scheduledOn: string;
dueDate: string;
pm: string;
/** Registry service selected for this work order; null keeps the optional field empty. */
serviceId: string | number | null;
/** Extra services beyond primary `pm` (Service & Notes). */
extraServices: string[];
/** Free-text note attached to the service (Service & Notes). */
@ -82,6 +84,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
scheduledOn: "",
dueDate: "",
pm: "",
serviceId: null,
extraServices: [],
pmNote: "",
avetaRequired: false,

View file

@ -4,6 +4,8 @@ import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { boardCreateErrorMessage } from "@/domain/work-orders/errors/board-create-errors";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard(
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create work order");
toast.error(boardCreateErrorMessage(error));
},
});
}

View file

@ -3,9 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
boardPatchValidationMessage,
boardPatchErrorMessage,
BoardPatchConflictError,
BoardPatchPartialSaveError,
BoardPatchValidationError,
NO_PERSISTABLE_FIELDS_MESSAGE,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import {
@ -58,10 +60,7 @@ async function applyBoardFieldOps(
): Promise<WorkOrderTableRow> {
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE);
}
const current = getRow?.(id);
@ -77,14 +76,7 @@ async function applyBoardFieldOps(
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
throw new BoardPatchPartialSaveError(latest, error);
}
throw error;
}
@ -155,9 +147,8 @@ export function useWorkOrderTableMutations(
invalidate();
},
onError: (error: Error, { id }) => {
const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow;
if (partialRow) {
onPatch(id, partialRow);
if (error instanceof BoardPatchPartialSaveError) {
onPatch(id, error.partialRow);
toast.error(error.message);
invalidate();
return;
@ -165,13 +156,10 @@ export function useWorkOrderTableMutations(
if (error instanceof BoardPatchConflictError && error.currentState) {
onPatch(id, error.currentState);
toast.error(error.message || "Conflict — refreshed with latest data");
} else if (error instanceof BoardPatchValidationError) {
clearPatch(id);
toast.error(boardPatchValidationMessage(error));
toast.error(boardPatchErrorMessage(error));
} else {
clearPatch(id);
toast.error(error.message || "Failed to update work order");
toast.error(boardPatchErrorMessage(error));
}
invalidate();
},

View file

@ -0,0 +1,33 @@
import { isRealWoNumber } from "@/domain/work-orders/utils/wo-closability";
/** Board WO numbers are an 11-digit APM number or an SH placeholder of at most 11 characters. */
export const BOARD_WO_NUMBER_MAX_LENGTH = 11;
export const WO_NUMBER_FORMAT_MESSAGE =
"Work order number must be 11 digits or an SH number (e.g. SH00001)";
export const WO_NUMBER_DOWNGRADE_MESSAGE =
"Cannot replace an APM work order number with an internal number";
export function sanitizeBoardWoNumberInput(raw: string): string {
return raw
.replace(/[^a-zA-Z0-9]/g, "")
.toUpperCase()
.slice(0, BOARD_WO_NUMBER_MAX_LENGTH);
}
export function isProvisionalWoNumber(woNumber: string): boolean {
return /^SH/i.test(woNumber.trim());
}
export function isValidBoardWoNumber(woNumber: string): boolean {
return /^\d{11}$/.test(woNumber) || /^SH\d{1,9}$/i.test(woNumber);
}
/**
* Only an already-saved real APM number is protected from being replaced by an
* SH placeholder. A brand-new WO has no saved number, so the rule never applies there.
*/
export function isWoNumberDowngrade(savedWoNumber: string, nextWoNumber: string): boolean {
return isRealWoNumber(savedWoNumber) && isProvisionalWoNumber(nextWoNumber);
}

View file

@ -47,6 +47,10 @@ export const queryKeys = {
[...queryKeys.employees.all, "list", params ?? {}] as const,
detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const,
},
teamMembers: {
all: ["teamMembers"] as const,
list: () => [...queryKeys.teamMembers.all, "list"] as const,
},
dashboard: {
all: ["dashboard"] as const,
stats: () => [...queryKeys.dashboard.all, "stats"] as const,
@ -56,6 +60,12 @@ export const queryKeys = {
byCategory: (category: string, parent?: string) =>
[...queryKeys.dropdownOptions.all, "byCategory", category, parent ?? ""] as const,
},
services: {
all: ["services"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
},
locations: {
all: ["locations"] as const,
list: (params?: Record<string, unknown>) =>

View file

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test/test-utils";
import { AddTeamMemberDialog } from "@/app/(protected)/team-members/_components/add-team-member-dialog";
import { useCreateTeamMember } from "@/domain/team-members/use-cases/use-create-team-member";
vi.mock("@/domain/team-members/use-cases/use-create-team-member", () => ({
useCreateTeamMember: vi.fn(),
}));
describe("AddTeamMemberDialog", () => {
const mutate = vi.fn();
const reset = vi.fn();
beforeEach(() => {
mutate.mockReset();
reset.mockReset();
vi.mocked(useCreateTeamMember).mockReturnValue({
mutate,
reset,
isPending: false,
error: null,
} as unknown as ReturnType<typeof useCreateTeamMember>);
});
it("requires the required fields and a Dispatcher service area", async () => {
const user = userEvent.setup();
renderWithProviders(<AddTeamMemberDialog open onClose={vi.fn()} />, { withAuth: false });
await user.click(screen.getByRole("button", { name: "Add Member" }));
expect(screen.getByText("Name is required.")).toBeInTheDocument();
expect(screen.getByText("Color is required.")).toBeInTheDocument();
expect(screen.getByText("Email is required.")).toBeInTheDocument();
expect(screen.getByText("Select at least one service area.")).toBeInTheDocument();
expect(mutate).not.toHaveBeenCalled();
});
it("resets role defaults and submits customized permissions", async () => {
const user = userEvent.setup();
renderWithProviders(<AddTeamMemberDialog open onClose={vi.fn()} />, { withAuth: false });
await user.type(screen.getByRole("textbox", { name: "Name" }), "Taylor Dispatcher");
await user.type(screen.getByRole("textbox", { name: "Email" }), "taylor@example.com");
await user.click(screen.getByRole("button", { name: "Select color #F59E0B" }));
await user.click(screen.getByRole("combobox", { name: "Role" }));
await user.click(screen.getByRole("option", { name: "Scheduler" }));
expect(screen.queryByLabelText("East")).not.toBeInTheDocument();
const createSites = screen.getByLabelText("Create sites");
expect(createSites).toBeChecked();
await user.click(createSites);
await user.click(screen.getByRole("button", { name: "Add Member" }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
name: "Taylor Dispatcher",
role: "Scheduler",
color: "#F59E0B",
email: "taylor@example.com",
serviceAreas: [],
permissionOverrides: { createSites: 2 },
}),
expect.any(Object),
);
}, 15000);
it("does not close while a member is being created", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.mocked(useCreateTeamMember).mockReturnValue({
mutate,
reset,
isPending: true,
error: null,
} as unknown as ReturnType<typeof useCreateTeamMember>);
renderWithProviders(<AddTeamMemberDialog open onClose={onClose} />, { withAuth: false });
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
it("clears a previous mutation error when reopened", () => {
const mutation = {
mutate,
reset,
isPending: false,
error: new Error("Email is already in use."),
} as unknown as ReturnType<typeof useCreateTeamMember>;
vi.mocked(useCreateTeamMember).mockReturnValue(mutation);
const { rerender } = renderWithProviders(<AddTeamMemberDialog open onClose={vi.fn()} />, {
withAuth: false,
});
expect(screen.getByText("Email is already in use.")).toBeInTheDocument();
mutation.error = null;
rerender(<AddTeamMemberDialog open={false} onClose={vi.fn()} />);
rerender(<AddTeamMemberDialog open onClose={vi.fn()} />);
expect(screen.queryByText("Email is already in use.")).not.toBeInTheDocument();
});
});

View file

@ -68,6 +68,8 @@ const roster: VendorCompanyRoster = {
zip: "63101",
googleMapsUrl: "",
notes: "",
areaId: "",
areaName: "",
technicians: [],
};
@ -134,6 +136,7 @@ function submitValues(
state: "MO",
zip: "63101",
notes: "",
areaId: "",
technicians,
};
}
@ -300,6 +303,7 @@ describe("useVendorRosterForm prototype defaults", () => {
state: "",
zip: "",
notes: "",
areaId: "",
technicians: [
{
contactName: "",

View file

@ -12,6 +12,8 @@ import {
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { renderWithProviders } from "@/test/test-utils";
import type { StateCode } from "@/data/areas";
import { pickVendorState } from "./vendor-state-select";
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }),
@ -58,16 +60,18 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
}
function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) {
function fillRequiredAddress({
street = "6735 Trippel Rd",
city = "Theodore",
state = "AL" as StateCode,
} = {}) {
fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), {
target: { value: street },
});
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
target: { value: city },
});
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
target: { value: state },
});
pickVendorState(state);
}
describe("VendorCreateModal validation", () => {

View file

@ -39,6 +39,8 @@ const vendor: VendorListItem = {
tradeSpecialties: "",
isActive: true,
preferredContact: "Phone",
areaId: "",
areaName: "",
};
function rosterWith(technicians: Array<Record<string, unknown>>) {
@ -57,6 +59,8 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
zip: "",
googleMapsUrl: "",
notes: "",
areaId: "",
areaName: "",
technicians,
},
isLoading: false,

View file

@ -11,11 +11,15 @@ import {
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { suggestAddresses } from "@/lib/address/vendor-address";
import { renderWithProviders } from "@/test/test-utils";
import { formatStateOptionLabel, normalizeStateCode } from "@/data/areas";
import { pickVendorState, stateField } from "./vendor-state-select";
const TYPED_STREET = "415 Murphy Rd";
const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0];
if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street");
const FIRST_SUGGESTION_STATE = normalizeStateCode(FIRST_SUGGESTION.state);
if (FIRST_SUGGESTION_STATE == null) throw new Error("expected a canonical suggestion state");
function AddressFormHarness({
onValid,
@ -67,9 +71,7 @@ describe("VendorRosterFormFields address autocomplete", () => {
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
FIRST_SUGGESTION.city,
);
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
FIRST_SUGGESTION.state,
);
expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE));
});
it("submits the settled structured parts, not the option label", async () => {
@ -104,14 +106,12 @@ describe("VendorRosterFormFields address autocomplete", () => {
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
FIRST_SUGGESTION.city,
);
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
FIRST_SUGGESTION.state,
);
expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE));
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(street).toHaveValue("");
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue("");
expect(stateField()).toHaveValue("");
});
});
@ -132,9 +132,7 @@ describe("VendorRosterFormFields map preview empty state", () => {
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
target: { value: "Theodore" },
});
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
target: { value: "AL" },
});
pickVendorState("AL");
expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();

View file

@ -0,0 +1,197 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { describe, expect, it, vi } from "vitest";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import { SERVICE_AREA_ADMIN_ONLY_MESSAGE } from "@/app/(protected)/vendors/_components/vendor-service-area-field";
import { VendorFilterOptions } from "@/app/(protected)/vendors/_components/vendor-filter-options";
import {
emptyVendorFilters,
serviceAreaFilterOptions,
} from "@/app/(protected)/vendors/_components/vendor-filters";
import {
emptyVendorCompanyRosterForm,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor";
import {
mapVendorRosterAdditivePatchToBackend,
mapVendorRosterToBackend,
} from "@/domain/vendors/mappers/vendor-roster-mapper";
import { mapVendorFacets, mapVendorListItem } from "@/domain/vendors/mappers/vendor-mapper";
import { renderWithProviders } from "@/test/test-utils";
import { pickVendorState, stateField } from "./vendor-state-select";
const AREAS: VendorFacetArea[] = [
{ id: "1", name: "East" },
{ id: "2", name: "Central" },
];
function Harness({
canAssignArea,
currentArea = null,
defaults = {},
onValid,
}: {
canAssignArea: boolean;
currentArea?: VendorFacetArea | null;
defaults?: Partial<VendorCompanyRosterFormValues>;
onValid: (values: VendorCompanyRosterFormValues) => void;
}) {
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
defaultValues: { ...emptyVendorCompanyRosterForm, ...defaults },
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(onValid)}>
<VendorRosterFormFields
control={control}
errors={formState.errors}
areaOptions={AREAS}
currentArea={currentArea}
canAssignArea={canAssignArea}
/>
<button type="submit">Submit</button>
</form>
);
}
const COMPLETE_COMPANY: Partial<VendorCompanyRosterFormValues> = {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
address: "1 Market St",
city: "St. Louis",
state: "MO",
};
describe("Vendor Service Area field", () => {
it("lets an admin assign one Service Area by stable id", async () => {
const onValid = vi.fn();
renderWithProviders(<Harness canAssignArea onValid={onValid} defaults={COMPLETE_COMPANY} />, {
withAuth: false,
});
const area = screen.getByRole("combobox", { name: "Service Area (optional)" });
expect(area).toBeEnabled();
fireEvent.change(area, { target: { value: "Cen" } });
fireEvent.click(screen.getByRole("option", { name: "Central" }));
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("2");
});
it("shows the saved Area read-only to non-admins and keeps it on submit", async () => {
const onValid = vi.fn();
renderWithProviders(
<Harness
canAssignArea={false}
currentArea={{ id: "9", name: "Archived Zone" }}
defaults={{ ...COMPLETE_COMPANY, areaId: "9" }}
onValid={onValid}
/>,
{ withAuth: false },
);
const area = screen.getByRole("combobox", { name: "Service Area (optional)" });
expect(area).toBeDisabled();
expect(area).toHaveValue("Archived Zone");
expect(screen.getByText(SERVICE_AREA_ADMIN_ONLY_MESSAGE)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("9");
});
it("does not derive the Service Area from the selected State", () => {
renderWithProviders(<Harness canAssignArea onValid={vi.fn()} />, { withAuth: false });
pickVendorState("CA");
expect(stateField()).toHaveValue("California (CA)");
expect(screen.getByRole("combobox", { name: "Service Area (optional)" })).toHaveValue("");
expect(screen.queryByText(/Area:/)).not.toBeInTheDocument();
});
});
describe("Vendor State field", () => {
it("is a closed list: free text is not kept and legacy names show as codes", () => {
renderWithProviders(
<Harness canAssignArea={false} onValid={vi.fn()} defaults={{ state: "missouri" }} />,
{ withAuth: false },
);
expect(stateField()).toHaveValue("Missouri (MO)");
fireEvent.change(stateField(), { target: { value: "Ontario" } });
expect(screen.queryByRole("option", { name: /Ontario/ })).not.toBeInTheDocument();
fireEvent.blur(stateField());
expect(stateField()).toHaveValue("Missouri (MO)");
});
});
describe("Service Area contracts", () => {
it("orders filter options Unassigned first, then names A–Z", () => {
expect(
serviceAreaFilterOptions([
{ id: "3", name: "West" },
{ id: "4", name: "california" },
{ id: "1", name: "East" },
]).map((option) => option.id),
).toEqual([VENDOR_AREA_UNASSIGNED, "4", "1", "3"]);
});
it("maps facet areas and directory Area fields from the API", () => {
expect(
mapVendorFacets({
Areas: [
{ Id: 1, Name: "East" },
{ Id: 0, Name: "" },
],
}).areas,
).toEqual([{ id: "1", name: "East" }]);
const row = mapVendorListItem({ Id: 1, AreaId: 2, AreaName: "Central" });
expect(row.areaId).toBe("2");
expect(row.areaName).toBe("Central");
});
it("round-trips form Areas, sends explicit null for Unassigned, and omits absent Areas", () => {
expect(mapVendorRosterToBackend({ name: "A", areaId: "3" }).areaId).toBe(3);
expect(mapVendorRosterToBackend({ name: "A", areaId: "" }).areaId).toBeNull();
expect(mapVendorRosterToBackend({ name: "A" })).not.toHaveProperty("areaId");
expect(
mapVendorRosterAdditivePatchToBackend({
rowVersion: "rv",
addTechnicians: [],
companyFields: { areaId: "" },
}).companyFields,
).toEqual({ areaId: null });
expect(
mapVendorRosterAdditivePatchToBackend({ rowVersion: "rv", addTechnicians: [] }),
).not.toHaveProperty("companyFields");
});
});
describe("Vendor Service Area filter section", () => {
it("sits between Trade and Location", () => {
const setDraft = vi.fn();
renderWithProviders(
<VendorFilterOptions
facets={{ companies: [], trades: [], areas: AREAS, locations: [], jobBuckets: [] }}
draft={emptyVendorFilters}
setDraft={setDraft}
/>,
{ withAuth: false },
);
expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([
"Company",
"Trade",
"Service Area",
"Location",
"Total Jobs",
]);
});
});

View file

@ -0,0 +1,15 @@
import { fireEvent, screen } from "@testing-library/react";
import { formatStateOptionLabel, type StateCode } from "@/data/areas";
export const STATE_FIELD_NAME = "State (required)";
export function stateField(): HTMLElement {
return screen.getByRole("combobox", { name: STATE_FIELD_NAME });
}
/** Picks a state from the closed vendor State list by its option label. */
export function pickVendorState(code: StateCode): void {
const label = formatStateOptionLabel(code);
fireEvent.change(stateField(), { target: { value: label } });
fireEvent.click(screen.getByRole("option", { name: label }));
}

View file

@ -46,6 +46,8 @@ const activeVendor: VendorListItem = {
tradeSpecialties: "Backflow Preventers",
isActive: true,
preferredContact: "Email",
areaId: "",
areaName: "",
};
const inactiveVendor = {
@ -67,6 +69,8 @@ const activeRoster: VendorCompanyRoster = {
zip: "",
googleMapsUrl: "",
notes: "",
areaId: "",
areaName: "",
technicians: [
{
id: 1,
@ -668,4 +672,30 @@ describe("VendorsListPage", () => {
expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument();
});
it("shows the Service Area column after State with Unassigned for companies without an Area", () => {
setupDefaults();
const assigned: VendorListItem = { ...activeVendor, areaId: "1", areaName: "East" };
const unassigned: VendorListItem = {
...activeVendor,
id: 9,
companyId: "co-9",
companyName: "No Area Co",
};
useVendorDirectoryList.mockImplementation(
(params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 2);
return params.isActive ? result([assigned, unassigned], 2) : result([], 0);
},
);
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
expect(headers.indexOf("Service Area")).toBe(headers.indexOf("State") + 1);
expect(headers.indexOf("Trade")).toBe(headers.indexOf("Service Area") + 1);
expect(
screen.getAllByTestId("vendor-text-service-area").map((cell) => cell.textContent),
).toEqual(["East", "Unassigned"]);
});
});

View file

@ -0,0 +1,105 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => {
const toastFn = Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() });
return { toast: toastFn };
});
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: { findByWoNumber: vi.fn() },
}));
function renderCell(value: string, currentId: string | number) {
const onSave = vi.fn();
renderWithProviders(
<EditableWONumberCell
value={value}
rescheduleCount={0}
currentId={currentId}
allOrders={[]}
onSave={onSave}
/>,
);
return onSave;
}
function typeAndCommit(nextValue: string) {
fireEvent.click(screen.getByText(/./, { selector: "span" }));
const input = screen.getByLabelText("Work order number");
fireEvent.change(input, { target: { value: nextValue } });
fireEvent.keyDown(input, { key: "Enter" });
}
describe("EditableWONumberCell SH placeholder numbers", () => {
beforeEach(() => {
vi.mocked(workOrdersApi.findByWoNumber).mockResolvedValue(null as never);
vi.mocked(toast).mockClear();
vi.mocked(toast.error).mockClear();
});
it("saves a freshly typed SH number on a brand-new inline work order", async () => {
const onSave = renderCell("", "draft");
typeAndCommit("SH00001");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00001"));
expect(toast).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("keeps a typed SH prefix instead of stripping it to digits", () => {
renderCell("", "draft");
fireEvent.click(screen.getByText("-"));
const input = screen.getByLabelText("Work order number");
fireEvent.change(input, { target: { value: "sh00001" } });
expect(input).toHaveValue("SH00001");
});
it("does not treat an unsaved inline draft number as a real number to protect", async () => {
const onSave = renderCell("12345678901", "draft");
typeAndCommit("SH00002");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00002"));
});
it("blocks replacing a saved real APM number with an SH number", async () => {
const onSave = renderCell("12345678901", 42);
typeAndCommit("SH00001");
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"Cannot replace an APM work order number with an internal number",
),
);
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByText("12345678901")).toBeInTheDocument();
});
it("lets a saved SH number be replaced by a real 11-digit number", async () => {
const onSave = renderCell("SH00001", 42);
typeAndCommit("12345678901");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("12345678901"));
});
it("still rejects a numeric number that is not 11 digits", () => {
const onSave = renderCell("", "draft");
typeAndCommit("12345");
expect(toast).toHaveBeenCalledWith(
"Work order number must be 11 digits or an SH number (e.g. SH00001)",
);
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
describe("service picker search placeholder", () => {
it("uses a type-neutral search placeholder in the wizard service picker", () => {
const draft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, pm: "" };
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[
{ id: "1", value: "HVAC" },
{ id: "2", value: "Plumbing" },
]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /select service/i }));
expect(screen.getByPlaceholderText("Search services…")).toBeTruthy();
expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull();
});
it("uses a type-neutral search placeholder in the table service picker popover", () => {
const anchor = document.createElement("button");
render(
<PmTypePickerPopover
anchorEl={anchor}
search=""
filtered={["HVAC", "Plumbing"]}
value=""
showAddDetails={false}
onSearchChange={vi.fn()}
onClose={vi.fn()}
onPick={vi.fn()}
onAddDetails={vi.fn()}
/>,
);
expect(screen.getByPlaceholderText("Search service…")).toBeTruthy();
expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull();
});
});

View file

@ -0,0 +1,46 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) {
const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow;
render(
<SlideOverCompletionTab
row={row}
isReadOnly={false}
media={[]}
onOpenCompletionDoc={onOpenCompletionDoc}
/>,
);
return onOpenCompletionDoc;
}
describe("SlideOverCompletionTab service gate", () => {
it("disables the completion document button until a service is selected", () => {
const onOpen = renderTab(" ");
const button = screen.getByRole("button", { name: /select service/i });
expect(button).toBeDisabled();
expect(
screen.getByText("Select a service before opening the completion document."),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /open completion document/i }),
).not.toBeInTheDocument();
fireEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it("opens the completion document when a service is selected", () => {
const onOpen = renderTab("HVAC Inspection");
const button = screen.getByRole("button", { name: /open completion document/i });
expect(button).toBeEnabled();
expect(screen.queryByText(/select service/i)).not.toBeInTheDocument();
fireEvent.click(button);
expect(onOpen).toHaveBeenCalledTimes(1);
});
});

View file

@ -79,6 +79,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
expect(onPatch).toHaveBeenCalledWith({
pm: "Electrical",
serviceId: "3",
extraServices: [],
vendorId: "",
vendorName: "",

View file

@ -0,0 +1,69 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
describe("WizardStepTypeSchedule — service clear on type change", () => {
it("clears the service and its vendor when the work-order type changes", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
type: "PM",
pm: "HVAC",
serviceId: "1",
extraServices: ["Plumbing"],
vendorId: "9",
vendorName: "Old Vendor",
techPhone: "555-0100",
};
render(
<WizardStepTypeSchedule draft={draft} users={USERS} onUpdate={vi.fn()} onPatch={onPatch} />,
);
fireEvent.click(screen.getByRole("button", { name: /Reactive/ }));
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "Reactive",
pm: "",
serviceId: null,
extraServices: [],
vendorId: "",
vendorName: "",
techPhone: "",
}),
);
});
it("keeps the selected service when the same type is re-selected", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
type: "PM",
pm: "HVAC",
serviceId: "1",
extraServices: ["Plumbing"],
vendorId: "9",
vendorName: "Old Vendor",
techPhone: "555-0100",
};
render(
<WizardStepTypeSchedule draft={draft} users={USERS} onUpdate={vi.fn()} onPatch={onPatch} />,
);
fireEvent.click(screen.getByRole("button", { name: /Scheduled preventive maintenance/ }));
const patch = onPatch.mock.calls[0][0] as Partial<WorkOrderWizardDraft>;
expect(patch).not.toHaveProperty("pm");
expect(patch).not.toHaveProperty("serviceId");
expect(patch).not.toHaveProperty("extraServices");
expect(patch).not.toHaveProperty("vendorId");
});
});

View file

@ -0,0 +1,116 @@
import { screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
PMTypeCell: () => <span>cell:service</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
VendorCell: () => <span>cell:vendor</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
ApptTimeCell: () => <span>cell:appt</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
StatusCell: () => <span>cell:status</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
DateCell: () => <span>cell:completed</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({
CompDocCell: () => <span>cell:doc</span>,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({
UpliftCell: () => <span>cell:uplift</span>,
}));
/** Documented left-to-right data column order for the Work Orders board. */
const DOCUMENTED_ORDER = [
"SITE",
"WO",
"TYPE OF WO",
"ASSIGNED TO",
"SCHEDULE ON",
"DUE DATE",
"SERVICE",
"VENDOR",
"APPT TIME",
"STATUS",
"COMPLETED DATE",
"COMP DOC",
"UPLIFT",
];
describe("Work Orders board column order", () => {
it("renders the documented header order with grip/flag before Site and actions after Uplift", () => {
renderWithProviders(
<table>
<WoTableHead />
</table>,
{ withAuth: false },
);
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent?.trim() ?? "");
expect(headers.filter(Boolean)).toEqual(expect.arrayContaining(DOCUMENTED_ORDER));
expect(WO_TABLE_COLS.map((col) => col.key)).toEqual([
"grip",
"flag",
"site",
"wo",
"type",
"assigned",
"sched",
"due",
"pm",
"vendor",
"appt",
"status",
"completed",
"doc",
"uplift",
"actions",
]);
expect(WO_TABLE_COLS.map((col) => col.label).filter(Boolean)).toEqual(DOCUMENTED_ORDER);
});
it("renders row cells under the matching headers: Status, Completed Date, Comp Doc, Uplift", () => {
const row = {
id: 7,
upliftSummary: { hasUplift: false, pendingCount: 0 },
} as WorkOrderTableRow;
renderWithProviders(
<table>
<tbody>
<tr data-testid="row">
<WoTableRowServiceCells
row={row}
vendors={[]}
pmTypes={[]}
handlers={{} as unknown as WoTableRowHandlers}
onPatchRow={vi.fn()}
/>
</tr>
</tbody>
</table>,
{ withAuth: false },
);
const cells = within(screen.getByTestId("row"))
.getAllByRole("cell")
.map((td) => td.textContent);
expect(cells).toEqual([
"cell:service",
"cell:vendor",
"cell:appt",
"cell:status",
"cell:completed",
"cell:doc",
"cell:uplift",
]);
});
});

View file

@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
}));
import { servicesApi } from "@/domain/services/api/services-api";
describe("servicesApi", () => {
beforeEach(() => {
apiGet.mockReset();
});
it("loads active services filtered by the selected work-order type", async () => {
apiGet.mockResolvedValue([
{
id: 7,
name: "HVAC inspection",
trade: "HVAC",
iconKey: "hvac-inspection",
isActive: true,
supportedWorkOrderTypes: [2, "Reactive"],
},
]);
const services = await servicesApi.getActiveForWorkOrderType("PM");
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: 2 },
});
expect(services[0]).toMatchObject({
id: 7,
name: "HVAC inspection",
trade: "HVAC",
supportedWorkOrderTypes: ["PM", "Reactive"],
});
});
it("does not request services for the unsupported Overdue type", async () => {
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
});
});

View file

@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
}));
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
describe("teamMembersApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
});
it("posts the pending team member contract without invitation fields", async () => {
apiPost.mockResolvedValueOnce({
data: {
id: "u-1",
name: "Taylor Dispatcher",
role: "Dispatcher",
pendingRegistration: true,
},
});
const input = {
name: "Taylor Dispatcher",
role: "Dispatcher" as const,
color: "#F59E0B",
email: "taylor@example.com",
phone: "555-0100",
serviceAreas: ["East"],
permissionOverrides: { deleteSites: 1 as const },
};
await expect(teamMembersApi.create(input)).resolves.toMatchObject({
id: "u-1",
pendingRegistration: true,
});
expect(apiPost).toHaveBeenCalledWith(API_PATHS.teamMember.create, input);
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("invite");
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("password");
});
});

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { mapTeamMemberListItem } from "@/domain/team-members/mappers/team-member-mapper";
describe("mapTeamMemberListItem", () => {
it("maps the existing User list response and derives inactive state", () => {
expect(
mapTeamMemberListItem({
Id: "u1",
Name: "Alex Smith",
RoleName: "Dispatcher",
Locations: "Central",
Email: "alex@example.com",
Status: "Inactive",
IsAccountOwner: true,
}),
).toEqual({
id: "u1",
name: "Alex Smith",
role: "Dispatcher",
serviceArea: "Central",
phone: "",
email: "alex@example.com",
color: "",
status: "Inactive",
isAccountOwner: true,
pendingRegistration: false,
});
});
});

View file

@ -419,3 +419,24 @@ describe("vendorsApi.getDirectoryList", () => {
});
});
});
describe("vendorsApi.getDirectoryList Service Area filter", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue({ Data: [], TotalCount: 0 });
});
it("sends areas[n] stable ids and the Unassigned sentinel", async () => {
await vendorsApi.getDirectoryList({ page: 1, areas: ["1", "__unassigned__"] });
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, {
searchParams: {
search: "",
page: 1,
pageSize: 12,
"areas[0]": "1",
"areas[1]": "__unassigned__",
},
});
});
});

View file

@ -40,6 +40,8 @@ describe("vendor roster mapper", () => {
zip: "",
googleMapsUrl: "",
notes: "",
areaId: "",
areaName: "",
technicians: [
{
id: 7,

View file

@ -40,6 +40,8 @@ const roster: VendorCompanyRoster = {
zip: "63101",
googleMapsUrl: "",
notes: "Preferred vendor",
areaId: "",
areaName: "",
technicians: [
{
id: 11,
@ -75,6 +77,7 @@ function formValues(
state: roster.state,
zip: roster.zip,
notes: roster.notes,
areaId: roster.areaId,
technicians: [newTechnician],
...overrides,
};

View file

@ -14,6 +14,8 @@ const roster: VendorCompanyRoster = {
zip: "63101",
googleMapsUrl: "http://maps.example.test/legacy",
notes: "Legacy values remain untouched",
areaId: "",
areaName: "",
technicians: [
{
id: 7,
@ -37,6 +39,7 @@ const values = {
state: roster.state,
zip: roster.zip,
notes: roster.notes,
areaId: roster.areaId,
technicians: roster.technicians.map((technician) => ({
id: technician.id,
contactName: technician.contactName,

View file

@ -140,6 +140,25 @@ describe("workOrdersApi.createFromBoard", () => {
locationId: 9,
});
});
it("includes the registry service id when selected in the wizard", async () => {
await workOrdersApi.createFromBoard({
...EMPTY_WIZARD_DRAFT,
type: "Reactive",
siteCode: "BK5",
pm: "HVAC inspection",
serviceId: 7,
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.workOrder.boardCreate,
expect.objectContaining({
workOrderType: 6,
primaryService: "HVAC inspection",
serviceId: 7,
}),
);
});
});
describe("workOrdersApi.update", () => {
@ -615,6 +634,28 @@ describe("workOrdersApi.patchBoardField", () => {
}),
).rejects.toBeInstanceOf(BoardPatchConflictError);
});
it("throws BoardPatchValidationError with DuplicateWoNumber code on 422", async () => {
apiPatchFn.mockResolvedValue({
ok: false,
status: 422,
json: async () => ({
code: "DuplicateWoNumber",
message: "WO number already exists.",
}),
});
await expect(
workOrdersApi.patchBoardField(10, {
field: "woNumber",
value: "12345678901",
workOrderVersion: "AAAAAAA=",
}),
).rejects.toMatchObject({
name: "BoardPatchValidationError",
code: "DuplicateWoNumber",
});
});
});
describe("workOrdersApi.createComment", () => {

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
BOARD_PATCH_CONFLICT_MESSAGE,
BOARD_PATCH_FAILED_MESSAGE,
BoardPatchConflictError,
BoardPatchValidationError,
DUPLICATE_WO_NUMBER_MESSAGE,
boardPatchErrorMessage,
} from "@/domain/work-orders/errors/board-patch-errors";
describe("boardPatchErrorMessage", () => {
it("maps a DuplicateWoNumber validation error to the duplicate-WO product copy", () => {
const error = new BoardPatchValidationError("DuplicateWoNumber", "WO number already exists.");
expect(boardPatchErrorMessage(error)).toBe(DUPLICATE_WO_NUMBER_MESSAGE);
});
it("never leaks backend validation text for unknown codes", () => {
const error = new BoardPatchValidationError("SomethingElse", "vendorId is required.");
expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_FAILED_MESSAGE);
});
it("maps a conflict error to the concurrent-edit copy", () => {
const error = new BoardPatchConflictError("stale", null);
expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_CONFLICT_MESSAGE);
});
});

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