Compare commits

...

66 commits

Author SHA1 Message Date
npalseahaven
e0390a9ace
Merge f2ba62fe79 into 6a6d519ed7 2026-09-28 23:32:10 +00:00
npal
f2ba62fe79 fix: stop checklist toggles from dropping fast consecutive updates
TanStack Query v5 only runs a mutate() call's onSuccess for the latest
call on a shared observer, so checking two items before the first
request resolved silently dropped the first item's onItemUpdated call,
leaving the portal's checkbox out of sync with the server. Switch to
mutateAsync so each call's result is handled independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:30:52 -05:00
Alexandre Brandizzi
6a6d519ed7
Merge pull request #276 from Sea-Haven-Industries/fix/ab/sh-403-reset-code-hardening
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / browser-smoke (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
fix(auth): send reset email and code in the request body
2026-09-25 22:21:20 +00:00
Alexandre Brandizzi
a554aa049e
Merge pull request #282 from Sea-Haven-Industries/fix/ab/sh-408-has-uplift-live-status
fix(work-orders): keep uplift history reachable on closed work orders
2026-09-25 22:21:17 +00:00
Alexandre Brandizzi
810fa5d5b0
Merge pull request #283 from Sea-Haven-Industries/fix/ab/sh-406-no-revoke-auto-approved
Hide Revoke from admins on auto-approved uplifts
2026-09-25 22:17:01 +00:00
Alexandre Brandizzi
a27695b217
Merge pull request #274 from Sea-Haven-Industries/feat/ab/sh-385-invite-registration
feat(team-members): invite registration wizard and resend invite (SH-385)
2026-09-25 22:16:36 +00:00
Alexandre Brandizzi
3cdb5e7d7b
Merge pull request #281 from Sea-Haven-Industries/feat/ab/sh-317-a4-pagination
feat(comp-doc): A4 multi-page completion document preview
2026-09-25 22:16:32 +00:00
Alexandre Brandizzi
727588ab52 fix(comp-doc): keep the page pill current on re-layout and stamp NOT REQUIRED on the sheet
The page being read is re-derived after every render and every observed
resize, not only when the page count changes. The NOT REQUIRED stamp is
rendered inside the first sheet, above its greyed-out content, instead of
floating over the scroll panel.
2026-09-25 18:49:13 -03:00
Alexandre Brandizzi
a2d03f9461 fix(work-orders): keep uplift history reachable on closed work orders
The Uplift column hid its trigger on a Completed or Canceled work order
with no live uplift, so once cancelled/revoked uplifts stop counting as
"has uplift", their read-only history had no way in. The cell now always
opens the uplifts dialog; closed work orders show a dash instead of Manage.
2026-09-25 18:14:53 -03:00
Alexandre Brandizzi
18127c0f30 fix(uplifts): hide Revoke from admins on auto-approved uplifts
Admins may revoke only admin-approved uplifts. The Work Order Uplifts
dialog showed Revoke to whoever requested an auto-approved uplift, so an
admin requester saw it and was sent into the optional-reason revoke flow.
2026-09-25 18:14:06 -03:00
Alexandre Brandizzi
dc2565e3fe feat(comp-doc): paginate the completion document preview into A4 sheets
Both the template preview and a work order's completion document render
through the shared frame, which now splits the letterhead and blocks into
A4-sized sheets with the brand bars on every sheet and shows Page X of Y
in the preview pill.
2026-09-25 18:03:33 -03:00
Alexandre Brandizzi
0b654bb07c Merge remote-tracking branch 'origin/main' into feat/ab/sh-385-invite-registration
# Conflicts:
#	src/api/api-paths.ts
2026-09-25 17:46:17 -03:00
Alexandre Brandizzi
3b7cf83332 fix(team-members): offer a retry when the invite cannot be checked
The invite page showed "invalid or has expired, ask your admin for a new
invite" for any failed resolve call, including offline, timeouts and 5xx.
A member on a flaky connection was sent to their admin for a resend, which
revokes a token that was still valid.

Only the server's invalid_invite answer now ends on "Invite unavailable".
Any other failure shows "Couldn't load your invite" with a Try again
button that re-runs resolve, without a global error toast. Regression
tests cover a network rejection and a 500 followed by a successful retry.
2026-09-25 17:45:08 -03:00
Alexandre Brandizzi
f068beb45e
Merge pull request #277 from Sea-Haven-Industries/fix/ab/sh-402-effective-permissions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(completion-docs): show template actions from the user's effective permissions
2026-09-25 20:31:22 +00:00
Alexandre Brandizzi
06f70c193d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
#	src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
2026-09-25 17:26:10 -03:00
Alexandre Brandizzi
e8cdb9814a
Merge pull request #273 from Sea-Haven-Industries/feat/ab/sh-334-site-delete
feat(sites): delete a site from the detail panel with an open work-order warning
2026-09-25 20:22:20 +00:00
Alexandre Brandizzi
8e02031152
Merge pull request #268 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-page
feat(completion-docs): manage completion document templates
2026-09-25 20:18:17 +00:00
Alexandre Brandizzi
7bde8aacf2 fix(sites): hold Delete until the reopened dialog's open work-order recheck lands
The open work-order query keeps its cached result between dialog opens, so on
reopen the background refetch ran while isLoading was false and Delete was
enabled with the previous count. A work order created at the site since the
last open could be deleted past without a warning.

Treat an in-flight fetch as an unknown count: Delete and View open work orders
wait, the stale copy is hidden and the checking spinner shows until the fresh
count arrives.
2026-09-25 17:17:20 -03:00
Alexandre Brandizzi
2b338b36c6 test(completion-docs): keep the dialog closed when dismissed mid-save
Dismissing the edit dialog with Escape or the Close button while an update
is still in flight must not bring it back when the save lands. The per-call
mutate callbacks that reopen it in view mode are dropped by TanStack Query
once the dialog unmounts, so this pins that behaviour: the test fails if the
save is moved to mutateAsync().then(), which would reopen the dialog.
2026-09-25 17:13:20 -03:00
Alexandre Brandizzi
2281c7c82d fix(sites): open the site's board when open work orders exceed the id cap
The delete dialog states the server's full open work-order count, but the
server returns at most 200 ids. "View open work orders" navigated with
that capped list, so a site with 240 open work orders showed 200, and an
empty list opened the unfiltered board.

The exact-id link is now used only when the ids cover the whole count.
Otherwise the link opens Work Orders filtered to the site and every open
status across all weeks, the ticket's "board filtered to that site". The
board drilldown now reads a `sites` param for this; `ids` still wins.
2026-09-25 17:10:51 -03:00
Alexandre Brandizzi
85a45e9fc0
Merge pull request #279 from Sea-Haven-Industries/feat/ab/sh-389-rejected-uplift-queue
feat(uplifts): add the Rejected archive tab to Uplift Approvals (SH-389)
2026-09-25 20:08:16 +00:00
Alexandre Brandizzi
090c977659 fix(uplifts): report a failed approvals queue once, inline, without the global toast 2026-09-25 17:03:15 -03:00
Alexandre Brandizzi
d866cf1b87 fix(completion-docs): hide the empty-search row when templates fail to load 2026-09-25 17:00:23 -03:00
Alexandre Brandizzi
73f639c271
Merge pull request #269 from Sea-Haven-Industries/feat/ab/sh-322-overdue-type
feat(work-orders): send Overdue as its own work order type
2026-09-25 19:57:00 +00:00
Alexandre Brandizzi
a031239259 fix(uplifts): keep the count and pager when a refetch fails over loaded rows 2026-09-25 16:56:35 -03:00
Alexandre Brandizzi
7c38ebd579 Merge commit 'd281cbd663b7b4106f104cb40207e4d5d1271387' into HEAD 2026-09-25 16:51:25 -03:00
Alexandre Brandizzi
b53d91b1da Merge remote-tracking branch 'origin/main' into HEAD 2026-09-25 16:48:47 -03:00
Alexandre Brandizzi
3ed45547db fix(uplifts): keep a failed queue from rendering as an empty archive 2026-09-25 16:48:46 -03:00
Alexandre Brandizzi
d281cbd663 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
#	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-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-25 16:46:17 -03:00
Alexandre Brandizzi
70501693c8 fix(uplifts): show an unavailable count pill while a queue is loading or failed 2026-09-25 13:43:11 -03:00
Alexandre Brandizzi
403bc66383
Merge pull request #271 from Sea-Haven-Industries/feat/ab/sh-386-password-policy
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(account): My Account page with a live password checklist (SH-386)
2026-09-25 16:35:20 +00:00
Alexandre Brandizzi
dcc9429da8
Merge pull request #270 from Sea-Haven-Industries/feat/ab/sh-331-sites-panel
feat(sites): Add Site modal and site detail panel with in-place edit
2026-09-25 16:29:09 +00:00
Alexandre Brandizzi
70e52937aa feat(uplifts): add the rejected uplifts archive tab to Uplift Approvals 2026-09-25 13:23:32 -03:00
Alexandre Brandizzi
3f3a3701c3 feat(sites): suggest addresses as Street Address is typed
SH-331 requires Street Address suggestions after 3 typed characters,
with a pick filling Street, City and State and unpicked typing kept as
free text. SH-272, which SH-331 consolidates, specifies those
suggestions as mocked, and the shared AddressAutocompleteField that
Vendors already uses (SH-271) was built for Sites to reuse. No Places
key is involved.

The Add Site modal and the detail panel's edit mode now render that
field. A pick writes all three values through their own controllers,
so the form marks them dirty and Save changes enables in edit mode.
The field gains an opt-in requiredMarker prop so Sites keeps its label
asterisk while Vendors stays without one.
2026-09-25 13:10:03 -03:00
Alexandre Brandizzi
6e0967eaf0
Merge pull request #267 from Sea-Haven-Industries/feat/ab/wo-ids-filter
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(notifications): SLA at-risk banner opening Work Orders on an exact id set
2026-09-25 15:58:37 +00:00
Alexandre Brandizzi
383d5c3c6c feat(team-members): warn when the invite to a corrected email could not be sent 2026-09-25 12:54:31 -03:00
Alexandre Brandizzi
9c19287172 test(completion-docs): keep the dialog closed when a save finishes after closing 2026-09-25 12:53:43 -03:00
Alexandre Brandizzi
d5cd0659a2 fix(completion-docs): gate template actions on the user's effective permissions
Add Template, Edit and Delete now follow the permissions the server
computes for the signed-in user, role defaults plus their individual
overrides, instead of role defaults alone. The actions stay hidden while
the permissions load or when they fail to load.
2026-09-25 12:34:01 -03:00
Alexandre Brandizzi
ca42d6e59f fix(team-members): cover mid-wizard invite loss and accept empty invite responses 2026-09-25 12:33:37 -03:00
Alexandre Brandizzi
976cd908e3 feat(team-members): invite registration wizard and resend invite (SH-385) 2026-09-25 12:26:42 -03:00
Alexandre Brandizzi
73090d05e4 fix(auth): keep change-password failures inline without a global toast 2026-09-25 12:26:31 -03:00
Alexandre Brandizzi
9bb0416346 fix(sites): block delete until the open work-order check succeeds 2026-09-25 12:24:16 -03:00
Alexandre Brandizzi
3ab9c9a65b Merge remote-tracking branch 'origin/feat/ab/sh-331-sites-panel' into feat/ab/sh-334-site-delete 2026-09-25 12:23:27 -03:00
Alexandre Brandizzi
fe0d1a2571 fix(sites): keep the legacy contact name when an update sends no contacts 2026-09-25 12:23:18 -03:00
Alexandre Brandizzi
297830ed35 fix(auth): send reset email and code in the request body
The verify call now carries the email the code was issued to, which the
API requires, and neither call puts the email or code in the URL.
2026-09-25 12:22:55 -03:00
Alexandre Brandizzi
7fed941435 test(visual): refresh sidebar baselines for the My Account link 2026-09-25 12:14:19 -03:00
Alexandre Brandizzi
c16b7e03e4 Merge remote-tracking branch 'origin/feat/ab/sh-331-sites-panel' into feat/ab/sh-334-site-delete
# Conflicts:
#	src/app/(protected)/locations/_components/location-delete-dialog.tsx
2026-09-25 11:59:29 -03:00
Alexandre Brandizzi
5bcc0f7081 feat(sites): delete a site from the detail panel with an open work-order warning
- Delete sits in the view-mode footer for Admin and Scheduler only; the row delete icon is gone
- Confirmation states the open work-order count and links to exactly those work orders
- Sites without open work orders confirm with the plain permanent-removal copy
- A server refusal keeps the site and shows the error
2026-09-25 11:59:08 -03:00
Alexandre Brandizzi
c5816f8907 fix(sites): name the site in the delete confirmation and toast 2026-09-25 11:55:09 -03:00
Alexandre Brandizzi
7de34d359e Merge branch 'feat/ab/wo-ids-filter' into feat/ab/sh-295-sla-alerts 2026-09-25 11:52:57 -03:00
Alexandre Brandizzi
74dd1292a4 fix(workorders): applying a panel filter replaces the exact id selection
The server honours only the id set when one is sent, so a facet applied during an ids drill-down showed as an active chip while doing nothing. Applying any facet now drops the selection; applying the panel unchanged keeps it. The ids param is also capped at the server limit whatever built the set.
2026-09-25 11:52:27 -03:00
Alexandre Brandizzi
cadfe1ee8d fix(auth): announce password rule changes to screen readers 2026-09-25 11:52:10 -03:00
Alexandre Brandizzi
ffc4a9da68 Merge remote-tracking branch 'origin/feat/ab/wo-ids-filter' into feat/ab/sh-334-site-delete 2026-09-25 11:47:31 -03:00
Alexandre Brandizzi
fac0d1885c test(visual): update sidebar baselines for the Completion Docs entry 2026-09-25 11:46:50 -03:00
Alexandre Brandizzi
551f6af208 refactor(sites): read optional site fields through one helper 2026-09-25 11:45:31 -03:00
Alexandre Brandizzi
a75b9c8be6 feat(sites): Add Site modal and site detail panel with in-place edit
- Sites page: Add Site modal with Site Code, Client, address, State/Area, optional Site Phone and Email, points of contact
- Duplicate Site Code shows inline under the field and keeps the modal open
- Row opens a non-modal detail panel; Street Address links to Google Maps; key-less map preview
- Edit switches the panel in place with Site Code read-only; Save needs a valid change, Cancel makes no call
- Site Phone is sent independently of the contacts; zip and title are carried through edits
- Retire the separate new/edit location pages
2026-09-25 11:43:30 -03:00
Alexandre Brandizzi
b6226ef1e6 test(completion-docs): give typing-heavy dialog tests the vendor-suite budget
Use user-event without per-keystroke delays and a 15 s budget per test, as
the vendor create modal suite does, so the dialog tests don't time out on a
loaded runner.
2026-09-25 11:39:42 -03:00
Alexandre Brandizzi
31e9db2d02 feat(notifications): SLA at-risk banner, 50% toast and breach acknowledgement
Dispatchers see a one-line banner and a one-time toast while a Reactive/Emergency work order is past half of its SEV response window; the banner opens the board on exactly those work orders and dismisses for the session. Missed deadlines stay in the notification center until acknowledged, and Clear all skips them.
2026-09-25 11:36:25 -03:00
Alexandre Brandizzi
e6250fba88 feat(account): My Account page with shared password checklist (SH-386)
Adds a My Account page, reachable from the sidebar, with the signed-in
user's read-only details and a Change Password section. The new password
is checked live against the shared password rules (6+ characters, an
uppercase letter, a number and a special character), which the invite
registration flow reuses. The current password is still verified by the
server first, and its rejection is shown on the current password field.
2026-09-25 11:16:59 -03:00
Alexandre Brandizzi
c1361dbbf3 Merge remote-tracking branch 'origin/feat/ab/wo-ids-filter' into feat/ab/sh-313-completion-templates-page 2026-09-25 11:14:07 -03:00
Alexandre Brandizzi
6d22d039ef fix(completion-docs): keep other rejected values out of the title field
Only the server's TitleRequired rejection is shown on the Document Title
field; any other rejected value is reported in a toast. Procedure names and
the extra safety note stop at the server's length limits.
2026-09-25 11:13:25 -03:00
Alexandre Brandizzi
5f8218b09a test(workorders): avoid Array.prototype.at outside the ES2020 lib 2026-09-25 11:11:09 -03:00
Alexandre Brandizzi
0a64f2dec7 Merge remote-tracking branch 'origin/feat/ab/wo-ids-filter' into feat/ab/sh-313-completion-templates-page 2026-09-25 11:08:26 -03:00
Alexandre Brandizzi
50b97a96d0 feat(completion-docs): manage completion document templates
Add the Completion Docs page under Data Management: a searchable template
table, a read-only detail panel, add and edit with a live document preview,
and delete behind a confirmation that counts the open work orders still
using the template and links to them. Add, edit and delete are hidden for
users whose role lacks the matching template permission.

The preview shares the work-order completion document's page frame,
header, safety bullets and procedure heading so the two stay identical.
2026-09-25 11:08:21 -03:00
Alexandre Brandizzi
8a1a12bfa1 feat(work-orders): send Overdue as its own work order type
Overdue maps to work order type 8 on create, inline correction, board
reads and the type filter, instead of the Other enum and the past-due
flag. It lists the PM service catalog and never sends a severity. Past
Due moves to its own toggle in the advanced filter Status section and
narrows the other filters.
2026-09-25 11:06:21 -03:00
Alexandre Brandizzi
e052dcb37a feat(workorders): filter board to an exact work-order id set
/workorders?ids=101,205,318 opens the board on exactly those work orders
through board/search's ids filter. The id set replaces every other
drill-down filter, a chip reads "Showing N selected work orders" and
clearing it returns to the weekly board. buildWorkOrderIdsHref builds
the link for alerts and linked-work-order lists.
2026-09-25 11:06:09 -03:00
204 changed files with 9295 additions and 969 deletions

View file

@ -26,7 +26,7 @@
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts |
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

@ -33,6 +33,9 @@ export const API_PATHS = {
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`,
completionTemplateLinkedWorkOrders: (id: string | number) =>
`workorders/completion-templates/${id}/linked-work-orders`,
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -83,6 +86,14 @@ export const API_PATHS = {
getList: "User",
create: "team-members",
detail: "team-members",
invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`,
myPermissions: "team-members/me/permissions",
},
teamMemberInvite: {
resolve: "team-member-invites/resolve",
sendCode: "team-member-invites/send-code",
verifyCode: "team-member-invites/verify-code",
complete: "team-member-invites/complete",
},
contact: {
getList: "Contact/GetContactList",

View file

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

View file

@ -0,0 +1,33 @@
import { Text } from "@/components/ui/text";
import type { AuthUser } from "@/domain/auth/types/auth-user";
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
return (
<div className="grid gap-1.5">
<Text variant="label" tone="muted" className="text-[12px]!">
{label}
</Text>
<Text variant="body" className="m-0 text-[14px]!">
{value || "—"}
</Text>
</div>
);
}
export function AccountSummaryCard({ user }: { user: AuthUser }) {
return (
<section
aria-label="Account details"
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="grid grid-cols-2 gap-3">
<ReadOnlyValue label="Name" value={user.fullname} />
<ReadOnlyValue label="Role" value={user.userRoles} />
</div>
<ReadOnlyValue label="Email" value={user.email} />
<Text variant="caption" tone="muted" className="text-[11.5px]!">
Contact your Admin to change your name, role, or email.
</Text>
</section>
);
}

View file

@ -0,0 +1,132 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { KeyRound } from "lucide-react";
import { PasswordChecklist } from "@/components/auth/password-checklist";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { Text } from "@/components/ui/text";
import {
CURRENT_PASSWORD_INCORRECT_MESSAGE,
changePasswordSchema,
type ChangePasswordFormValues,
} from "@/domain/auth/schemas/change-password-schema";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
const EMPTY_VALUES: ChangePasswordFormValues = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
export function ChangePasswordSection() {
const changePassword = useChangePassword();
const {
register,
handleSubmit,
control,
reset,
setError,
formState: { errors },
} = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: EMPTY_VALUES,
mode: "onTouched",
});
const values = useWatch({ control });
const newPassword = values.newPassword ?? "";
const canSubmit = changePasswordSchema.safeParse(values).success;
const serverError = changePassword.error?.message;
const showServerError =
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
const onSubmit = (form: ChangePasswordFormValues) => {
changePassword.mutate(
{
Currentpassword: form.currentPassword,
Newpassword: form.newPassword,
Confirmpassword: form.confirmPassword,
},
{
onSuccess: () => reset(EMPTY_VALUES),
onError: (error) => {
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
}
},
},
);
};
return (
<Box
component="form"
aria-labelledby="change-password-heading"
onSubmit={handleSubmit(onSubmit)}
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="flex items-center gap-2">
<KeyRound size={15} aria-hidden="true" className="text-primary" />
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
Change Password
</Text>
</div>
<FormField
label="Current password"
htmlFor="account-current-password"
required
error={Boolean(errors.currentPassword)}
helperText={errors.currentPassword?.message}
>
<PasswordField
{...register("currentPassword")}
id="account-current-password"
autoComplete="current-password"
error={Boolean(errors.currentPassword)}
/>
</FormField>
<FormField label="New password" htmlFor="account-new-password" required>
<PasswordField
{...register("newPassword")}
id="account-new-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
/>
<PasswordChecklist id="account-password-rules" password={newPassword} />
</FormField>
<FormField
label="Confirm new password"
htmlFor="account-confirm-password"
required
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="account-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
{showServerError && <Text variant="error">{serverError}</Text>}
<div className="flex justify-end">
<Button
type="submit"
disabled={!canSubmit}
loading={changePassword.isPending}
loadingText="Updating…"
>
Update password
</Button>
</div>
</Box>
);
}

View file

@ -0,0 +1,24 @@
import { Box } from "@mui/material";
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
import { PageHeader } from "@/components/ui/page-header";
import { useAuthContext } from "@/providers/auth-context";
export default function MyAccountPage() {
const { user } = useAuthContext();
return (
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="My Account"
subtitle="Review your account and manage your password"
/>
<Box className="grid max-w-md gap-5 px-5 pb-5">
{user != null && <AccountSummaryCard user={user} />}
<ChangePasswordSection />
</Box>
</Box>
);
}

View file

@ -0,0 +1,20 @@
import type { ReactNode } from "react";
export function CompletionTemplateAddLink({
onClick,
children,
}: {
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className="self-start"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)", textAlign: "left" }}
>
{children}
</button>
);
}

View file

@ -0,0 +1,42 @@
import { Text } from "@/components/ui/text";
function initials(name: string): string {
return name
.trim()
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
export function CompletionTemplateCreatedBy({ name }: { name: string | null }) {
if (name === null) {
return (
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
—
</Text>
);
}
return (
<span className="flex items-center gap-2">
<span
aria-hidden
className="flex shrink-0 items-center justify-center rounded-full"
style={{
width: 22,
height: 22,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-muted)",
color: "var(--color-primary)",
}}
>
{initials(name)}
</span>
<Text variant="caption" sx={{ fontSize: 13 }}>
{name}
</Text>
</span>
);
}

View file

@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
import {
AutoPopulatedFields,
FieldLabel,
SectionTitle,
StandardSafetyBullets,
} from "./completion-template-sections";
function ReadOnlyField({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<Text
variant="label"
tone="muted"
sx={{ fontSize: 12, fontWeight: 500, display: "block", marginBottom: "6px" }}
>
{label}
</Text>
{children}
</div>
);
}
function ProcedureList({ template }: { template: CompletionTemplate }) {
if (template.procedures.length === 0) {
return (
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
{COMPLETION_TEMPLATE_COPY.noProcedureDetail}
</Text>
);
}
return (
<ol className="m-0 list-none p-0">
{template.procedures.map((procedure, index) => (
<li key={index} style={{ marginBottom: 12 }}>
<Text variant="label" sx={{ fontSize: 13, fontWeight: 600, display: "block", mb: 0.5 }}>
{procedure.name || `Procedure ${index + 1}`}
</Text>
{descriptionLines(procedure.description).map((line, lineIndex) => (
<Text key={lineIndex} variant="body" sx={{ fontSize: 13, lineHeight: 1.5, my: 0.5 }}>
{line}
</Text>
))}
</li>
))}
</ol>
);
}
/** Everything a template holds, read-only. */
export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) {
return (
<div className="grid gap-3.5">
<SectionTitle>Completion Document</SectionTitle>
<ReadOnlyField label="Document Title">
<Text variant="body" sx={{ fontSize: 13 }}>
{template.name || "—"}
</Text>
</ReadOnlyField>
<AutoPopulatedFields />
<div>
<FieldLabel>Safety Notes</FieldLabel>
<StandardSafetyBullets extra={template.extraSafetyNote} />
</div>
<div>
<FieldLabel hint="(optional)">Procedures</FieldLabel>
<ProcedureList template={template} />
</div>
<div className="grid grid-cols-2 gap-3">
<ReadOnlyField label="Last Updated">
<Text variant="body" sx={{ fontSize: 13 }}>
{formatTemplateDate(template.lastUpdatedAt)}
</Text>
</ReadOnlyField>
<ReadOnlyField label="Created By">
<CompletionTemplateCreatedBy name={template.createdByName} />
</ReadOnlyField>
</div>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { FileText, Pencil, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { IconCloseButton } from "@/components/ui/icon-close-button";
import { Text } from "@/components/ui/text";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
export function CompletionTemplateDialogHeader({
title,
subtitle,
onClose,
}: {
title: string;
subtitle: string;
onClose: () => void;
}) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<Text
as="div"
variant="heading"
className="truncate"
sx={{ fontSize: 16, fontWeight: 700, lineHeight: 1.1 }}
>
{title}
</Text>
<Text as="div" variant="caption" tone="muted" className="truncate" sx={{ fontSize: 13 }}>
{subtitle}
</Text>
</div>
</div>
<IconCloseButton onClick={onClose} />
</div>
);
}
type FooterProps = {
mode: CompletionTemplateDialogMode;
permissions: CompletionTemplatePermissions;
isSaving: boolean;
onCancel: () => void;
onSubmit: () => void;
onEdit: () => void;
onDelete: () => void;
};
export function CompletionTemplateDialogFooter({
mode,
permissions,
isSaving,
onCancel,
onSubmit,
onEdit,
onDelete,
}: FooterProps) {
const footerStyle = {
padding: "12px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
flexShrink: 0,
};
if (mode !== "view") {
return (
<div className="flex items-center justify-end gap-3" style={footerStyle}>
<DialogCancelButton onClick={onCancel} disabled={isSaving} />
<Button onClick={onSubmit} disabled={isSaving} className="gap-1.5">
{mode === "create" ? <Plus size={13} /> : <Save size={13} />}
{mode === "create"
? COMPLETION_TEMPLATE_COPY.addTemplate
: COMPLETION_TEMPLATE_COPY.saveChanges}
</Button>
</div>
);
}
return (
<div
className={`flex items-center gap-3 ${permissions.canDelete ? "justify-between" : "justify-end"}`}
style={footerStyle}
>
{permissions.canDelete && (
<Button variant="ghost" onClick={onDelete} className="gap-1.5 text-destructive">
<Trash2 size={13} /> Delete
</Button>
)}
{permissions.canEdit && (
<Button onClick={onEdit} className="gap-1.5">
<Pencil size={13} /> Edit
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,21 @@
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
export function dialogHeading(
template: CompletionTemplate | null,
mode: CompletionTemplateDialogMode,
): { title: string; subtitle: string } {
if (template === null) {
return {
title: "New Completion Document Template",
subtitle: "Completion Document Template",
};
}
if (mode === "edit") return { title: template.name, subtitle: "Editing template" };
const createdBy = template.createdByName ?? "—";
return {
title: template.name,
subtitle: `Created by ${createdBy} · Updated ${formatTemplateDate(template.lastUpdatedAt)}`,
};
}

View file

@ -0,0 +1,102 @@
import { Dialog, DialogTitle } from "@mui/material";
import { useState } from "react";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { CompletionTemplateDetail } from "./completion-template-detail";
import {
CompletionTemplateDialogFooter,
CompletionTemplateDialogHeader,
} from "./completion-template-dialog-chrome";
import { dialogHeading } from "./completion-template-dialog-heading";
import { CompletionTemplateForm } from "./completion-template-form";
import { CompletionTemplatePreview } from "./completion-template-preview";
import { DeleteCompletionTemplateDialog } from "./delete-completion-template-dialog";
import {
useCompletionTemplateDialog,
type CompletionTemplateDialogMode,
} from "./use-completion-template-dialog";
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
type CompletionTemplateDialogProps = {
/** `null` adds a new template. */
template: CompletionTemplate | null;
initialMode: CompletionTemplateDialogMode;
permissions: CompletionTemplatePermissions;
onClose: () => void;
onSaved: (template: CompletionTemplate) => void;
};
/** View, add or edit one template beside its document preview. */
export function CompletionTemplateDialog({
template,
initialMode,
permissions,
onClose,
onSaved,
}: CompletionTemplateDialogProps) {
const dialog = useCompletionTemplateDialog({ template, initialMode, onClose, onSaved });
const [confirmingDelete, setConfirmingDelete] = useState(false);
const heading = dialogHeading(template, dialog.mode);
return (
<Dialog
open
onClose={onClose}
maxWidth={false}
aria-labelledby="completion-template-dialog-title"
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1040px, calc(100vw - 32px))",
maxWidth: "none",
background: "var(--color-bg-muted)",
},
},
}}
>
<DialogTitle id="completion-template-dialog-title" className="sr-only">
{heading.title}
</DialogTitle>
<CompletionTemplateDialogHeader
title={heading.title}
subtitle={heading.subtitle}
onClose={onClose}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompletionTemplatePreview values={dialog.previewValues} isEditing={dialog.isEditing} />
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
{dialog.isEditing ? (
<CompletionTemplateForm form={dialog.form} />
) : (
template !== null && <CompletionTemplateDetail template={template} />
)}
</div>
</div>
</div>
<CompletionTemplateDialogFooter
mode={dialog.mode}
permissions={permissions}
isSaving={dialog.isSaving}
onCancel={dialog.cancel}
onSubmit={() => void dialog.submit()}
onEdit={dialog.startEditing}
onDelete={() => setConfirmingDelete(true)}
/>
{template !== null && permissions.canDelete && (
<DeleteCompletionTemplateDialog
template={template}
open={confirmingDelete}
onClose={() => setConfirmingDelete(false)}
onDeleted={onClose}
/>
)}
</Dialog>
);
}

View file

@ -0,0 +1,64 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FormField } from "@/components/ui/form-field";
import { TextField } from "@/components/ui/text-field";
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
import { CompletionTemplateProceduresEditor } from "./completion-template-procedures-editor";
import { CompletionTemplateSafetyNoteField } from "./completion-template-safety-note-field";
import {
AutoPopulatedFields,
FieldLabel,
SectionTitle,
StandardSafetyBullets,
} from "./completion-template-sections";
type CompletionTemplateFormProps = {
form: UseFormReturn<CompletionTemplateFormValues>;
};
function TitleField({ form }: CompletionTemplateFormProps) {
return (
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<FormField
label="Document Title"
htmlFor="completion-template-title"
required
error={fieldState.error !== undefined}
helperText={fieldState.error?.message}
>
<TextField
id="completion-template-title"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Completion document title"
error={fieldState.error !== undefined}
/>
</FormField>
)}
/>
);
}
/** The editable template: title, fixed references, the optional note and procedures. */
export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) {
return (
<div className="grid gap-3.5">
<SectionTitle>Completion Document</SectionTitle>
<TitleField form={form} />
<AutoPopulatedFields />
<div className="grid gap-1">
<FieldLabel>Safety Notes</FieldLabel>
<StandardSafetyBullets />
<CompletionTemplateSafetyNoteField form={form} />
</div>
<div>
<FieldLabel hint="(optional)">Procedures</FieldLabel>
<CompletionTemplateProceduresEditor form={form} />
</div>
</div>
);
}

View file

@ -0,0 +1,139 @@
import { FileText } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame";
import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header";
import { CompDocProcedureHeading } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading";
import { CompDocSafetyBullets } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets";
import {
AUTO_POPULATED_FIELDS,
COMPLETION_TEMPLATE_COPY,
} from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
export interface CompletionTemplatePreviewValues {
name: string;
extraSafetyNote: string | null;
procedures: CompletionTemplateProcedure[];
}
type CompletionTemplatePreviewProps = {
values: CompletionTemplatePreviewValues;
isEditing: boolean;
};
function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
<FileText size={13} />
{isEditing ? "Live preview" : "Document preview"}
{pageIndicatorLabel(page)}
</span>
</div>
);
}
function PreviewTitle({ name }: { name: string }) {
const title = name.trim();
return (
<>
<Text
as="h2"
variant="title"
family="body"
sx={{
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: title ? "#161E38" : "#9CA3AF",
margin: "16px 0 4px",
}}
>
{title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument}
</Text>
<Text variant="body" sx={{ fontSize: 12, color: "#58595B", margin: "2px 0 12px" }}>
{AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix}
</Text>
</>
);
}
/** One block per procedure (each kept whole on a page), or the empty-state line. */
function previewSections(procedures: CompletionTemplateProcedure[]): ReactNode[] {
if (procedures.length === 0) {
return [
<Text
key="empty"
variant="body"
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
>
{COMPLETION_TEMPLATE_COPY.noProcedurePreview}
</Text>,
];
}
return procedures.map((procedure, index) => (
<section key={index} aria-label={`Preview procedure ${index + 1}`}>
<CompDocProcedureHeading>
{procedure.name.trim() || `Procedure ${index + 1}`}
</CompDocProcedureHeading>
{descriptionLines(procedure.description).map((line, lineIndex) => (
<Text
key={lineIndex}
variant="body"
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
>
{line}
</Text>
))}
</section>
));
}
/** The template rendered on the same page, header and safety bullets as a work order's document. */
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const note = values.extraSafetyNote?.trim() ?? "";
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<PreviewPill isEditing={isEditing} page={page} />
<div className="flex min-h-0 flex-1 flex-col" data-testid="completion-template-preview">
<CompDocDocumentFrame
onPageChange={setPage}
header={
<>
<CompDocPreviewHeader />
<PreviewTitle name={values.name} />
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
</>
}
sections={previewSections(values.procedures)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
import { CompletionTemplateAddLink } from "./completion-template-add-link";
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
type ProceduresEditorProps = {
form: UseFormReturn<CompletionTemplateFormValues>;
};
const DESCRIPTION_PLACEHOLDER =
"Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well.";
function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) {
return (
<div className="grid gap-2.5">
<Controller
control={form.control}
name={`procedures.${index}.name`}
render={({ field }) => (
<TextField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Procedure name (e.g. Backflow Procedure)"
slotProps={{
htmlInput: {
"aria-label": `Procedure ${index + 1} name`,
maxLength: PROCEDURE_NAME_MAX_LENGTH,
},
}}
/>
)}
/>
<Controller
control={form.control}
name={`procedures.${index}.description`}
render={({ field }) => (
<TextField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
multiline
minRows={5}
maxRows={8}
placeholder={DESCRIPTION_PLACEHOLDER}
slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} description` } }}
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
/>
)}
/>
</div>
);
}
/** Procedures print in the order they are listed here. */
export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) {
const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" });
return (
<div className="grid gap-3">
{fields.map((field, index) => (
<fieldset
key={field.id}
aria-label={`Procedure ${index + 1}`}
className="m-0 min-w-0"
style={{
padding: 12,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<Text
variant="caption"
tone="muted"
sx={{
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Procedure {index + 1}
</Text>
<CompletionTemplateRemoveButton
label={`Remove procedure ${index + 1}`}
onClick={() => remove(index)}
/>
</div>
<ProcedureFields form={form} index={index} />
</fieldset>
))}
<CompletionTemplateAddLink onClick={() => append({ name: "", description: "" })}>
+ Add procedure
</CompletionTemplateAddLink>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { X } from "lucide-react";
export function CompletionTemplateRemoveButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
>
<X size={14} />
</button>
);
}

View file

@ -0,0 +1,55 @@
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
import { TextField } from "@/components/ui/text-field";
import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
import { CompletionTemplateAddLink } from "./completion-template-add-link";
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
type SafetyNoteFieldProps = {
form: UseFormReturn<CompletionTemplateFormValues>;
};
/** At most one template-specific note, printed after the standard bullets. */
export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) {
const note = useWatch({ control: form.control, name: "extraSafetyNote" });
const setNote = (value: string | null) =>
form.setValue("extraSafetyNote", value, { shouldDirty: true });
if (note === null) {
return (
<CompletionTemplateAddLink onClick={() => setNote("")}>
+ Add safety note
</CompletionTemplateAddLink>
);
}
return (
<div className="flex items-start gap-2">
<Controller
control={form.control}
name="extraSafetyNote"
render={({ field }) => (
<TextField
multiline
minRows={3}
maxRows={8}
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Type safety note…"
slotProps={{
htmlInput: {
"aria-label": "Extra safety note",
maxLength: EXTRA_SAFETY_NOTE_MAX_LENGTH,
},
}}
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
/>
)}
/>
<div style={{ marginTop: 6 }}>
<CompletionTemplateRemoveButton label="Remove safety note" onClick={() => setNote(null)} />
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants";
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
export function SectionTitle({ children }: { children: ReactNode }) {
return (
<Text as="div" variant="heading" family="body" sx={{ fontSize: 16, fontWeight: 600 }}>
{children}
</Text>
);
}
export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) {
return (
<div className="flex items-baseline gap-1.5" style={{ marginBottom: 4 }}>
<Text
variant="caption"
tone="muted"
sx={{ fontSize: 11, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.06em" }}
>
{children}
</Text>
{hint !== undefined && (
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
{hint}
</Text>
)}
</div>
);
}
export function AutoPopulatedFields() {
return (
<div>
<FieldLabel>Auto-populated fields</FieldLabel>
<ul className="m-0 flex list-none flex-wrap items-center gap-1.5 p-0">
{AUTO_POPULATED_FIELDS.map((field) => (
<li
key={field}
className="inline-flex items-center"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-muted)",
fontSize: 12,
padding: "2px 8px",
borderRadius: 4,
}}
>
{field}
</li>
))}
</ul>
</div>
);
}
/** The standard bullets every generated document prints; never editable per template. */
export function StandardSafetyBullets({ extra }: { extra?: string | null }) {
return (
<ul aria-label="Safety notes" style={{ margin: "0 0 8px", paddingLeft: 18 }}>
{SAFETY_BULLETS.map((bullet) => (
<li
key={bullet}
style={{ fontSize: 12.5, color: "var(--color-text-muted)", margin: "2px 0" }}
>
{bullet}
</li>
))}
{Boolean(extra) && (
<li style={{ fontSize: 12.5, margin: "2px 0", whiteSpace: "pre-wrap" }}>{extra}</li>
)}
</ul>
);
}

View file

@ -0,0 +1,162 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
import { Eye, FileText, Pencil, Search } from "lucide-react";
import type { MouseEvent } from "react";
import { TableLoadingRow } from "@/components/ui/table-state-rows";
import { Text } from "@/components/ui/text";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
const HEAD_CELL_SX = {
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
background: "var(--color-bg-muted)",
padding: "11px 14px",
whiteSpace: "nowrap",
} as const;
const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const;
type CompletionTemplatesTableProps = {
templates: CompletionTemplate[];
isLoading: boolean;
/** The load failed: the page shows the error, so there is no search result to explain. */
hasError: boolean;
canEdit: boolean;
onView: (template: CompletionTemplate) => void;
onEdit: (template: CompletionTemplate) => void;
};
function RowAction({
label,
icon: Icon,
onClick,
}: {
label: string;
icon: typeof Eye;
onClick: () => void;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={(event: MouseEvent) => {
event.stopPropagation();
onClick();
}}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
>
<Icon size={15} />
</button>
);
}
function EmptyRow() {
return (
<TableRow>
<TableCell colSpan={4} sx={{ padding: 0, borderBottom: 0 }}>
<div
className="flex flex-col items-center justify-center gap-2 text-center"
style={{ padding: "64px 16px" }}
>
<Search size={22} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="body" sx={{ fontSize: 14, fontWeight: 600 }}>
{COMPLETION_TEMPLATE_COPY.emptySearchTitle}
</Text>
<Text variant="description" tone="muted" sx={{ fontSize: 12.5 }}>
{COMPLETION_TEMPLATE_COPY.emptySearchHint}
</Text>
</div>
</TableCell>
</TableRow>
);
}
function TemplateRow({
template,
canEdit,
onView,
onEdit,
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
template: CompletionTemplate;
}) {
return (
<TableRow hover onClick={() => onView(template)} sx={{ cursor: "pointer", height: 52 }}>
<TableCell sx={BODY_CELL_SX}>
<span className="inline-flex items-center gap-2.5">
<span
aria-hidden
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 28,
height: 28,
background: "var(--color-bg-muted)",
color: "var(--color-primary)",
}}
>
<FileText size={14} />
</span>
{template.name}
</span>
</TableCell>
<TableCell sx={{ ...BODY_CELL_SX, color: "text.secondary" }}>
{formatTemplateDate(template.lastUpdatedAt)}
</TableCell>
<TableCell sx={BODY_CELL_SX}>
<CompletionTemplateCreatedBy name={template.createdByName} />
</TableCell>
<TableCell sx={BODY_CELL_SX}>
<span className="flex items-center gap-1">
<RowAction label="View details" icon={Eye} onClick={() => onView(template)} />
{canEdit && <RowAction label="Edit" icon={Pencil} onClick={() => onEdit(template)} />}
</span>
</TableCell>
</TableRow>
);
}
export function CompletionTemplatesTable({
templates,
isLoading,
hasError,
canEdit,
onView,
onEdit,
}: CompletionTemplatesTableProps) {
return (
<Table
stickyHeader
size="small"
aria-label="Completion document templates"
sx={{ minWidth: 700 }}
>
<TableHead>
<TableRow>
<TableCell sx={{ ...HEAD_CELL_SX, width: 360 }}>Document Title</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 140 }}>Last Updated</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 180 }}>Created By</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 90 }}>
<span className="sr-only">Actions</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoadingRow colSpan={4} />}
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
{templates.map((template) => (
<TemplateRow
key={template.id}
template={template}
canEdit={canEdit}
onView={onView}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}

View file

@ -0,0 +1,98 @@
import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { Text } from "@/components/ui/text";
import type {
CompletionTemplate,
LinkedWorkOrders,
} from "@/domain/completion-templates/types/completion-template";
import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
type DeleteCompletionTemplateDialogProps = {
template: CompletionTemplate;
open: boolean;
onClose: () => void;
onDeleted: () => void;
};
function deleteMessage(name: string, linked: LinkedWorkOrders): string {
if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`;
const plural = linked.count === 1 ? "" : "s";
return (
`This will permanently remove "${name}". It's still linked to ${linked.count} open work ` +
`order${plural} through its Service — deleting it won't change that Service, but new ` +
"documents generated for those work orders will fall back to a generic procedure until " +
"reassigned. This cannot be undone."
);
}
function LinkedWorkOrdersMessage({
name,
linked,
}: {
name: string;
linked: ReturnType<typeof useLinkedWorkOrders>;
}) {
if (linked.isPending) {
return (
<div className="flex items-center gap-2" role="status">
<CircularProgress size={16} />
<Text variant="description" tone="muted">
Checking linked work orders…
</Text>
</div>
);
}
if (linked.isError) {
return (
<Text variant="error">
Couldn't check which work orders use this template. Close this and try again.
</Text>
);
}
return <Text variant="description">{deleteMessage(name, linked.data)}</Text>;
}
/** Deleting always asks first, and names the open work orders that still depend on the template. */
export function DeleteCompletionTemplateDialog({
template,
open,
onClose,
onDeleted,
}: DeleteCompletionTemplateDialogProps) {
const navigate = useNavigate();
const linked = useLinkedWorkOrders(open ? template.id : null);
const remove = useDeleteCompletionTemplate();
const linkedIds = linked.data?.workOrderIds ?? [];
function confirmDelete() {
remove.mutate(template.id, { onSuccess: onDeleted });
}
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Delete this template?</DialogTitle>
<DialogContent>
<LinkedWorkOrdersMessage name={template.name} linked={linked} />
</DialogContent>
<DialogActions>
<DialogCancelButton onClick={onClose} />
{linkedIds.length > 0 && (
<Button variant="outline" onClick={() => void navigate(buildWorkOrderIdsHref(linkedIds))}>
View linked work orders
</Button>
)}
<Button
variant="destructive"
onClick={confirmDelete}
disabled={!linked.isSuccess || remove.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,109 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import {
toCompletionTemplateFormValues,
toCompletionTemplateInput,
} from "@/domain/completion-templates/mappers/completion-template-mapper";
import {
completionTemplateFormSchema,
type CompletionTemplateFormValues,
} from "@/domain/completion-templates/schemas/completion-template-schema";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import {
isTitleRequiredError,
useCreateCompletionTemplate,
useUpdateCompletionTemplate,
} from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
import type { CompletionTemplatePreviewValues } from "./completion-template-preview";
export type CompletionTemplateDialogMode = "create" | "view" | "edit";
type UseCompletionTemplateDialogOptions = {
template: CompletionTemplate | null;
initialMode: CompletionTemplateDialogMode;
onClose: () => void;
onSaved: (template: CompletionTemplate) => void;
};
function usePreviewValues(
control: ReturnType<typeof useForm<CompletionTemplateFormValues>>["control"],
): CompletionTemplatePreviewValues {
const watched = useWatch({ control });
return {
name: watched.name ?? "",
extraSafetyNote: watched.extraSafetyNote ?? null,
procedures: (watched.procedures ?? []).map((procedure) => ({
name: procedure?.name ?? "",
description: procedure?.description ?? "",
})),
};
}
export function useCompletionTemplateDialog({
template,
initialMode,
onClose,
onSaved,
}: UseCompletionTemplateDialogOptions) {
const [mode, setMode] = useState<CompletionTemplateDialogMode>(
template === null ? "create" : initialMode,
);
const form = useForm<CompletionTemplateFormValues>({
resolver: zodResolver(completionTemplateFormSchema),
defaultValues: toCompletionTemplateFormValues(template),
});
const previewValues = usePreviewValues(form.control);
const create = useCreateCompletionTemplate();
const update = useUpdateCompletionTemplate();
function showServerTitleError(error: Error) {
if (!isTitleRequiredError(error)) return;
form.setError(
"name",
{ message: COMPLETION_TEMPLATE_COPY.titleRequired },
{ shouldFocus: true },
);
}
const submit = form.handleSubmit((values) => {
const input = toCompletionTemplateInput(values);
if (template === null) {
create.mutate(input, { onSuccess: onClose, onError: showServerTitleError });
return;
}
update.mutate(
{ template, input },
{
onSuccess: (saved) => {
form.reset(toCompletionTemplateFormValues(saved));
setMode("view");
onSaved(saved);
},
onError: showServerTitleError,
},
);
});
/** Discards unsaved edits: back to the template as it was, or closed when creating. */
function cancel() {
if (template === null) {
onClose();
return;
}
form.reset(toCompletionTemplateFormValues(template));
setMode("view");
}
return {
mode,
isEditing: mode !== "view",
form,
previewValues,
submit,
cancel,
startEditing: () => setMode("edit"),
isSaving: create.isPending || update.isPending,
};
}

View file

@ -0,0 +1,17 @@
import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions";
export interface CompletionTemplatePermissions {
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
/** Template actions follow the signed-in user's effective permissions, never their role alone. */
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const permissions = usePermissions();
return {
canCreate: hasPermission(permissions, "createCompletionDocTemplates"),
canEdit: hasPermission(permissions, "editCompletionDocTemplates"),
canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"),
};
}

View file

@ -0,0 +1,99 @@
import { Box } from "@mui/material";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { SearchInput } from "@/components/ui/search-input";
import { Text } from "@/components/ui/text";
import {
COMPLETION_TEMPLATE_COPY,
templateCountLabel,
} from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { useCompletionTemplates } from "@/domain/completion-templates/use-cases/use-completion-templates";
import { useDebounce } from "@/hooks/use-debounce";
import { CompletionTemplateDialog } from "./_components/completion-template-dialog";
import { CompletionTemplatesTable } from "./_components/completion-templates-table";
import type { CompletionTemplateDialogMode } from "./_components/use-completion-template-dialog";
import { useCompletionTemplatePermissions } from "./_components/use-completion-template-permissions";
type OpenDialog = { template: CompletionTemplate | null; mode: CompletionTemplateDialogMode };
export default function CompletionDocsPage() {
const permissions = useCompletionTemplatePermissions();
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 250);
const all = useCompletionTemplates();
const filtered = useCompletionTemplates(debouncedSearch);
const [dialog, setDialog] = useState<OpenDialog | null>(null);
const templates = filtered.data ?? [];
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
title={COMPLETION_TEMPLATE_COPY.pageTitle}
subtitle={templateCountLabel(all.data?.length ?? 0)}
actions={
<div className="flex items-center gap-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={COMPLETION_TEMPLATE_COPY.searchPlaceholder}
aria-label="Search document title"
className="w-56"
/>
{permissions.canCreate && (
<Button
onClick={() => setDialog({ template: null, mode: "create" })}
className="gap-1.5"
>
<Plus size={13} /> {COMPLETION_TEMPLATE_COPY.addTemplate}
</Button>
)}
</div>
}
/>
{filtered.error !== null && (
<QueryErrorPanel
className="mt-3 shrink-0"
compact
message={filtered.error.message || "Failed to load templates."}
onRetry={() => void filtered.refetch()}
/>
)}
<div
className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-border"
style={{ background: "var(--card)" }}
>
<CompletionTemplatesTable
templates={templates}
isLoading={filtered.isPending}
hasError={filtered.error !== null}
canEdit={permissions.canEdit}
onView={(template) => setDialog({ template, mode: "view" })}
onEdit={(template) => setDialog({ template, mode: "edit" })}
/>
</div>
<div className="shrink-0 px-4 py-2">
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
{templateCountLabel(templates.length)}
</Text>
</div>
{dialog !== null && (
<CompletionTemplateDialog
key={dialog.template?.id ?? "new"}
template={dialog.template}
initialMode={dialog.mode}
permissions={permissions}
onClose={() => setDialog(null)}
onSaved={(template) => setDialog({ template, mode: "view" })}
/>
)}
</Box>
);
}

View file

@ -0,0 +1,94 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import {
DUPLICATE_SITE_CODE_MESSAGE,
isDuplicateSiteCodeError,
} from "@/domain/locations/utils/site-code-conflict";
import { SiteFormFields } from "./site-form-fields";
interface AddSiteDialogProps {
open: boolean;
onClose: () => void;
}
/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */
export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) {
const createLocation = useCreateLocation();
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: locationDefaultValues,
});
const close = () => {
form.reset(locationDefaultValues);
createLocation.reset();
onClose();
};
const onSubmit = (values: LocationFormValues) => {
createLocation.mutate(values, {
onSuccess: close,
onError: (error) => {
if (isDuplicateSiteCodeError(error)) {
form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE });
}
},
});
};
return (
<Dialog
open={open}
onClose={() => {
if (!createLocation.isPending) {
close();
}
}}
fullWidth
maxWidth="sm"
aria-labelledby="add-site-title"
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
>
<Box component="form" noValidate onSubmit={form.handleSubmit(onSubmit)}>
<DialogTitle id="add-site-title">
Add Site
<Typography
variant="body2"
component="span"
className="block"
sx={{ color: "text.secondary" }}
>
Create a new client site record.
</Typography>
</DialogTitle>
<DialogContent dividers>
<SiteFormFields form={form} />
</DialogContent>
<DialogActions>
<Button variant="text" onClick={close} disabled={createLocation.isPending}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={createLocation.isPending}>
{createLocation.isPending ? "Adding..." : "Add Site"}
</Button>
</DialogActions>
</Box>
</Dialog>
);
}

View file

@ -0,0 +1,64 @@
import { useMemo, useState } from "react";
import { Controller, type Control, type FieldError } from "react-hook-form";
import { Autocomplete, TextField } from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { useDebounce } from "@/hooks/use-debounce";
type ClientOption = Pick<AccountListItem, "id" | "name">;
interface ClientSelectProps {
control: Control<LocationFormValues>;
error?: FieldError;
/** Stored client, shown even when it is not in the first page of search results. */
initialClient?: ClientOption;
}
/** Searchable client (account) picker bound to `accountId`. */
export function ClientSelect({ control, error, initialClient }: ClientSelectProps) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 });
const options = useMemo<ClientOption[]>(() => {
const items: ClientOption[] = data?.items ?? [];
const hasInitial =
initialClient !== undefined &&
items.some((item) => String(item.id) === String(initialClient.id));
return initialClient === undefined || hasInitial ? items : [initialClient, ...items];
}, [data?.items, initialClient]);
return (
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete<ClientOption>
options={options}
loading={isLoading}
getOptionLabel={(option) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={options.find((option) => String(option.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onBlur={field.onBlur}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setSearch(value);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Client"
required
placeholder="Search clients..."
error={Boolean(error)}
helperText={error?.message}
/>
)}
/>
)}
/>
);
}

View file

@ -202,7 +202,7 @@ export function LocationContactsFieldArray({
<Box ref={rootRef}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Contacts
Points of contact
</Typography>
<Button
size="small"
@ -210,7 +210,7 @@ export function LocationContactsFieldArray({
startIcon={<AddIcon fontSize="small" />}
onClick={() => append({ name: "", phone: "" })}
>
Add Contact
Add point of contact
</Button>
</Stack>
<Stack spacing={1.5} className="mt-2">

View file

@ -1,42 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
interface LocationDeleteDialogProps {
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
target: LocationListItem | null;
}
export function LocationDeleteDialog({
isPending,
onClose,
onConfirm,
target,
}: LocationDeleteDialogProps) {
return (
<Dialog open={Boolean(target)} onClose={onClose}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,143 +0,0 @@
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
import {
Autocomplete,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { LocationContactsFieldArray } from "./location-contacts-field-array";
import { StateSelect } from "./state-select";
interface SharedFieldsProps {
control: Control<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
interface LocationDetailsFieldsProps extends SharedFieldsProps {
accountOptions: AccountListItem[];
isLoadingAccounts: boolean;
onAccountSearchChange: (value: string) => void;
}
export function LocationDetailsFields({
accountOptions,
control,
errors,
isLoadingAccounts,
onAccountSearchChange,
register,
}: LocationDetailsFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
onAccountSearchChange(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
);
}
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
);
}

View file

@ -1,141 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { normalizeStateCode } from "@/data/areas";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { useDebounce } from "@/hooks/use-debounce";
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
export default function LocationFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const [accountSearch, setAccountSearch] = useState("");
const debouncedAccountSearch = useDebounce(accountSearch, 300);
const { data: location, isLoading, error } = useLocationDetail(id);
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
search: debouncedAccountSearch,
page: 1,
pageSize: 50,
});
const createLocation = useCreateLocation();
const updateLocation = useUpdateLocation();
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: locationDefaultValues,
});
useEffect(() => {
if (location && isEdit) {
reset({
name: location.name ?? "",
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: normalizeStateCode(location.state) ?? "",
zipCode: location.zipCode ?? "",
contacts:
location.contacts && location.contacts.length > 0
? location.contacts
: [{ name: location.contact ?? "", phone: location.phone ?? "" }],
contactEmail: location.contactEmail ?? "",
status: (location.status as LocationFormValues["status"]) || "Active",
});
}
}, [location, isEdit, reset]);
const isSaving = createLocation.isPending || updateLocation.isPending;
const onSubmit = (values: LocationFormValues) => {
if (isEdit && id) {
updateLocation.mutate({ id, values });
return;
}
createLocation.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
<Button variant="text" onClick={() => navigate("/locations")}>
Back to list
</Button>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<LocationDetailsFields
accountOptions={accountOptions}
control={control}
errors={errors}
isLoadingAccounts={isLoadingAccounts}
onAccountSearchChange={setAccountSearch}
register={register}
/>
<LocationContactFields control={control} errors={errors} register={register} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -1,9 +1,6 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
@ -11,7 +8,6 @@ import {
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
@ -20,84 +16,82 @@ import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onEdit: (row: LocationListItem) => void;
onOpen: (row: LocationListItem) => void;
rows: LocationListItem[];
selectedId?: string | null;
}
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
const COLUMN_COUNT = 7;
export function LocationsTable({
isLoading,
onOpen,
rows,
selectedId = null,
}: LocationsTableProps) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">Site Code</TableCell>
<TableCell className="font-semibold">Client</TableCell>
<TableCell className="font-semibold">Street Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
<TableCell className="font-semibold">POC Name</TableCell>
<TableCell className="font-semibold">POC Phone</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
{isLoading && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No sites found</Typography>
</TableCell>
</TableRow>
) : (
)}
{!isLoading &&
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableRow
key={String(row.id)}
hover
selected={String(row.id) === selectedId}
onClick={() => onOpen(row)}
className="cursor-pointer"
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onEdit(row)}
onClick={(event) => {
event.stopPropagation();
onOpen(row);
}}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>
<StateAreaCell state={row.state} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => onDelete(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</TableContainer>

View file

@ -0,0 +1,110 @@
import { useNavigate } from "react-router";
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders";
import { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
interface SiteDeleteDialogProps {
open: boolean;
siteId: string;
siteCode: string;
onClose: () => void;
onDeleted: () => void;
}
const CHECK_FAILED_MESSAGE =
"Couldn't check this site for open work orders. Try again before deleting it.";
function describeDeletion(siteCode: string, openCount: number): string {
if (openCount === 0) {
return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`;
}
const noun = openCount === 1 ? "work order" : "work orders";
return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`;
}
/** Delete confirmation; warns with the open work-order count and links to those work orders. */
export function SiteDeleteDialog({
open,
siteId,
siteCode,
onClose,
onDeleted,
}: SiteDeleteDialogProps) {
const navigate = useNavigate();
const deleteLocation = useDeleteLocation();
const {
data: openWorkOrders,
isLoading,
isError,
isFetching,
refetch,
} = useSiteOpenWorkOrders(siteId, open);
const openCount = openWorkOrders?.count ?? 0;
// Without a current count the warning cannot be trusted, so Delete waits for a successful check.
// `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands.
const countUnknown = isLoading || isFetching || isError;
// The server caps the id list; when it doesn't cover the stated count, open the site's whole board.
const openIds = openWorkOrders?.workOrderIds ?? [];
const openWorkOrdersHref =
openIds.length > 0 && openIds.length >= openCount
? buildWorkOrderIdsHref(openIds)
: buildSiteOpenWorkOrdersHref(siteId);
return (
<Dialog
open={open}
onClose={() => {
if (!deleteLocation.isPending) {
onClose();
}
}}
aria-labelledby="site-delete-title"
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
>
<DialogTitle id="site-delete-title">Delete this site?</DialogTitle>
<DialogContent>
{isFetching && !isError && (
<CircularProgress size={20} aria-label="Checking open work orders" />
)}
{isError && <DialogContentText role="alert">{CHECK_FAILED_MESSAGE}</DialogContentText>}
{!countUnknown && (
<DialogContentText>{describeDeletion(siteCode, openCount)}</DialogContentText>
)}
</DialogContent>
<DialogActions>
<Button variant="text" onClick={onClose} disabled={deleteLocation.isPending}>
Cancel
</Button>
{isError && (
<Button variant="outlined" disabled={isFetching} onClick={() => void refetch()}>
Try again
</Button>
)}
{!countUnknown && openCount > 0 && (
<Button variant="outlined" onClick={() => navigate(openWorkOrdersHref)}>
View open work orders
</Button>
)}
<Button
variant="contained"
color="error"
disabled={countUnknown || deleteLocation.isPending}
onClick={() => deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
>
{deleteLocation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,68 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box, Button, Stack } from "@mui/material";
import {
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { SiteFormFields } from "./site-form-fields";
import { toSiteFormValues } from "./site-form-values";
interface SiteDetailEditProps {
siteId: string;
location: Location;
/** Back to view mode: after Cancel (no API call) or after a successful save. */
onDone: () => void;
}
/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */
export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) {
const updateLocation = useUpdateLocation();
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: toSiteFormValues(location),
mode: "onChange",
});
const { isDirty, isValid } = form.formState;
const initialClient =
location.accountId != null && location.clientName
? { id: location.accountId, name: location.clientName }
: undefined;
const onSubmit = (values: LocationFormValues) => {
updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone });
};
return (
<Box
component="form"
noValidate
onSubmit={form.handleSubmit(onSubmit)}
className="flex min-h-0 flex-1 flex-col"
aria-label="Edit site"
>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteFormFields form={form} siteCodeReadOnly initialClient={initialClient} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
<Button variant="text" onClick={onDone} disabled={updateLocation.isPending}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={!isDirty || !isValid || updateLocation.isPending}
>
{updateLocation.isPending ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}

View file

@ -0,0 +1,132 @@
import { useContext, useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
import { IconCloseButton } from "@/components/ui/icon-close-button";
import type { Location } from "@/domain/locations/types/location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { canDeleteSites } from "@/lib/auth/user-utils";
import { AuthContext } from "@/providers/auth-context";
import { SiteDeleteDialog } from "./site-delete-dialog";
import { SiteDetailEdit } from "./site-detail-edit";
import { SiteDetailView } from "./site-detail-view";
export const SITE_PANEL_WIDTH = 420;
interface SiteDetailPanelProps {
/** Selected site id; `null` closes the panel. */
siteId: string | null;
onClose: () => void;
}
function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) {
return (
<Stack
direction="row"
className="border-b px-4 py-3"
sx={{ alignItems: "flex-start", justifyContent: "space-between", borderColor: "divider" }}
>
<Box>
<Typography variant="h6" component="h2">
{location?.name || "Site"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{location?.clientName || " "}
</Typography>
</Box>
<IconCloseButton label="Close site details" onClick={onClose} />
</Stack>
);
}
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
const [mode, setMode] = useState<"view" | "edit">("view");
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles);
const { data: location, isLoading, error } = useLocationDetail(siteId);
return (
<>
<PanelHeader location={location} onClose={onClose} />
{isLoading && (
<Box className="flex justify-center p-8">
<CircularProgress size={28} />
</Box>
)}
{Boolean(error) && (
<Alert severity="error" className="m-4">
{error instanceof Error ? error.message : "Failed to load site"}
</Alert>
)}
{location !== undefined && mode === "edit" && (
<SiteDetailEdit siteId={siteId} location={location} onDone={() => setMode("view")} />
)}
{location !== undefined && mode === "view" && (
<>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteDetailView location={location} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
{canDelete && (
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
Delete
</Button>
)}
<Button variant="contained" onClick={() => setMode("edit")}>
Edit
</Button>
</Stack>
<SiteDeleteDialog
open={isDeleteOpen}
siteId={siteId}
siteCode={location.name ?? ""}
onClose={() => setIsDeleteOpen(false)}
onDeleted={onClose}
/>
</>
)}
</>
);
}
/**
* Non-modal site panel: the table stays interactive, so clicking another row swaps the
* content in place. Escape closes it unless a popup inside already handled the key.
*/
export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) {
const open = siteId !== null;
useEffect(() => {
if (!open) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
return (
<Drawer
anchor="right"
variant="persistent"
open={open}
slotProps={{
paper: {
role: "region",
"aria-label": "Site details",
className: "flex h-full flex-col border-l shadow-lg",
sx: { width: SITE_PANEL_WIDTH, maxWidth: "100vw" },
},
}}
>
{siteId !== null && <PanelBody key={siteId} siteId={siteId} onClose={onClose} />}
</Drawer>
);
}

View file

@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { Link, Stack, Typography } from "@mui/material";
import { getAreaForState } from "@/data/areas";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { SiteMapPreview } from "./site-map-preview";
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack spacing={0.25}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{label}
</Typography>
<Typography variant="body2" component="div">
{children}
</Typography>
</Stack>
);
}
function formatState(state?: string): string {
const area = getAreaForState(state);
const code = state?.trim() ?? "";
if (!code) {
return "—";
}
return area === null ? code : `${code} · Area: ${area}`;
}
/** Read-only site details, in the prototype order. */
export function SiteDetailView({ location }: { location: Location }) {
const contacts = location.contacts ?? [];
return (
<Stack spacing={2}>
<Field label="Site Code">{location.name || "—"}</Field>
<Field label="Client">{location.clientName || "—"}</Field>
<Field label="Street Address">
{Boolean(location.address) && (
<Link
href={buildSiteMapsUrl(location)}
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
{location.address}
</Link>
)}
{!location.address && "—"}
</Field>
<Stack direction="row" spacing={2}>
<Field label="City">{location.city || "—"}</Field>
<Field label="State">{formatState(location.state)}</Field>
</Stack>
<SiteMapPreview
address={location.address}
city={location.city}
state={location.state}
zipCode={location.zipCode}
/>
<Field label="Site Phone">{location.sitePhone || "—"}</Field>
<Field label="Site Email">{location.contactEmail || "—"}</Field>
<Field label="Points of contact">
{contacts.length === 0 && "—"}
<Stack component="ul" spacing={0.5} className="m-0 list-none p-0">
{contacts.map((contact, index) => (
<li key={contact.id ?? `${contact.name}-${index}`}>
{index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name}
{Boolean(contact.phone) && ` · ${contact.phone}`}
</li>
))}
</Stack>
</Field>
</Stack>
);
}

View file

@ -0,0 +1,111 @@
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
import { Stack, TextField } from "@mui/material";
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
import { normalizeStateCode } from "@/data/areas";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { ClientSelect } from "./client-select";
import { LocationContactsFieldArray } from "./location-contacts-field-array";
import { SiteMapPreview } from "./site-map-preview";
import { StateSelect } from "./state-select";
interface SiteFormFieldsProps {
form: UseFormReturn<LocationFormValues>;
/** Edit mode: Site Code is immutable after creation. */
siteCodeReadOnly?: boolean;
initialClient?: Pick<AccountListItem, "id" | "name">;
}
/** Shared Add Site / edit-mode fields, in the prototype order. */
export function SiteFormFields({
form,
siteCodeReadOnly = false,
initialClient,
}: SiteFormFieldsProps) {
const {
control,
register,
formState: { errors },
} = form;
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
const { field: addressField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
return (
<Stack spacing={2}>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
onChange={(event) => field.onChange(event.target.value.toUpperCase())}
label="Site Code"
placeholder="e.g. AB12"
required={!siteCodeReadOnly}
disabled={siteCodeReadOnly}
error={Boolean(errors.name)}
helperText={
errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined)
}
slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }}
fullWidth
/>
)}
/>
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
<AddressAutocompleteField
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");
}}
label="Street Address"
required
requiredMarker
error={Boolean(errors.address)}
helperText={
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...cityField}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("sitePhone")}
label="Site Phone (optional)"
placeholder="(000) 000-0000"
error={Boolean(errors.sitePhone)}
helperText={errors.sitePhone?.message}
fullWidth
slotProps={{ htmlInput: { maxLength: 20 } }}
/>
<TextField
{...register("contactEmail")}
label="Site Email (optional)"
placeholder="site@company.com"
type="email"
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
</Stack>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
</Stack>
);
}

View file

@ -0,0 +1,24 @@
import { normalizeStateCode } from "@/data/areas";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */
export function toSiteFormValues(location: Location): LocationFormValues {
const contacts =
location.contacts && location.contacts.length > 0
? location.contacts
: [{ name: location.contact ?? "", phone: location.phone ?? "" }];
return {
name: location.name ?? "",
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: normalizeStateCode(location.state) ?? "",
sitePhone: location.sitePhone ?? "",
contactEmail: location.contactEmail ?? "",
contacts,
zipCode: location.zipCode ?? "",
title: location.title ?? "",
};
}

View file

@ -0,0 +1,39 @@
import { Box, Typography } from "@mui/material";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
export function SiteMapPreview(props: SiteMapPreviewProps) {
const complete = hasCompleteSiteAddress(props);
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
</Typography>
{complete && (
<Box
component="iframe"
title="Site location map"
src={buildSiteMapEmbedUrl(props)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded border-0"
sx={{ height: 140 }}
/>
)}
{!complete && (
<Box
className="mt-1 flex items-center justify-center rounded border border-dashed"
sx={{ height: 140, borderColor: "divider" }}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
</Box>
)}
</Box>
);
}

View file

@ -1 +0,0 @@
export { default } from "../_components/location-form";

View file

@ -1,5 +1,4 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useCallback, useMemo, useState } from "react";
import {
Alert,
Box,
@ -10,25 +9,24 @@ import {
TextField,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
import { useDebounce } from "@/hooks/use-debounce";
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
import { AddSiteDialog } from "./_components/add-site-dialog";
import { AreaFacet } from "./_components/area-facet";
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
import { LocationsTable } from "./_components/locations-table";
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
export default function LocationsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteLocation = useDeleteLocation();
const closePanel = useCallback(() => setSelectedId(null), []);
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
@ -47,18 +45,11 @@ export default function LocationsListPage() {
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
deleteLocation.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<Box className="flex flex-col gap-4 p-4">
<Box
className="flex flex-col gap-4 p-4"
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
@ -67,34 +58,17 @@ export default function LocationsListPage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Locations
Sites
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
<AreaFacet
value={selectedAreas}
onChange={(zones) => {
@ -109,24 +83,24 @@ export default function LocationsListPage() {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search locations..."
placeholder="Search sites..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/locations/new")}>
+ New Location
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
Add Site
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
{error instanceof Error ? error.message : "Failed to load sites"}
</Alert>
)}
<LocationsTable
isLoading={isLoading}
rows={rows}
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
onDelete={setDeleteTarget}
selectedId={selectedId}
onOpen={(row) => setSelectedId(String(row.id))}
/>
<TablePagination
component="div"
@ -140,12 +114,8 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
</Box>
);
}

View file

@ -1 +0,0 @@
export { default } from "./_components/location-form";

View file

@ -0,0 +1,26 @@
import { Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite";
/** Re-sends the registration invite to an active member who has not registered yet. */
export function ResendInviteButton({ member }: { member: TeamMemberDetail }) {
const resendInvite = useResendTeamMemberInvite();
if (!member.pendingRegistration || member.status !== "Active") {
return null;
}
return (
<Button
variant="outline"
onClick={() => resendInvite.mutate({ id: member.id, email: member.email })}
loading={resendInvite.isPending}
loadingText="Sending…"
startIcon={<Mail size={15} aria-hidden="true" />}
>
Resend invite
</Button>
);
}

View file

@ -37,6 +37,7 @@ import {
import { getFormErrors } from "./team-member-permissions";
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
import { ResendInviteButton } from "./resend-invite-button";
import {
useTeamMemberDetail,
useUpdateTeamMember,
@ -206,6 +207,7 @@ function MemberEditForm({
}
function DrawerFooter({
member,
editing,
form,
setForm,
@ -215,6 +217,7 @@ function DrawerFooter({
onEdit,
onSave,
}: {
member: TeamMemberDetail;
editing: boolean;
form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
@ -257,7 +260,8 @@ function DrawerFooter({
</Stack>
</Stack>
) : (
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Stack direction="row" sx={{ justifyContent: "flex-end", gap: 1.5 }}>
<ResendInviteButton member={member} />
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
{currentMember !== undefined && (
<DrawerFooter
member={currentMember}
editing={editor.editing}
form={editor.form}
setForm={editor.setForm}

View file

@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record<string, string> = {
};
export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys);
export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number];
const DISPATCHER_DEFAULTS = new Set([
"createVendors",
"editVendors",

View file

@ -0,0 +1,52 @@
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
// A rejection is terminal, so the archive ends with the decision and its reason
// instead of an Actions column.
const REJECTED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Rejected By",
"Rejected At",
"Rejection Reason",
] as const;
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
pending: PENDING_HEADER_CELLS,
approved: APPROVED_HEADER_CELLS,
rejected: REJECTED_HEADER_CELLS,
};

View file

@ -16,14 +16,20 @@ import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
UPLIFT_APPROVAL_HEADER_CELLS,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import {
RejectedEmptyState,
RejectedRowCells,
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
@ -236,65 +242,84 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
{tab === "pending" && (
<>
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
</TableCell>
</>
)}
{tab === "approved" && (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell>
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
{tab === "rejected" && <RejectedRowCells row={row} />}
</TableRow>
);
}
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
if (tab === "rejected") return <RejectedEmptyState />;
if (tab === "approved") {
return (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
);
}
return (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
);
}
// A failed queue is not an empty one: the page's error alert explains it, so the
// empty-state copy must not also claim the list has nothing in it.
function UpliftApprovalsEmptyRow({
tab,
colSpan,
hasError,
}: {
tab: UpliftApprovalTab;
colSpan: number;
hasError: boolean;
}) {
if (hasError) return null;
return (
<TableRow>
<TableCell colSpan={colSpan} align="center" className="py-10">
<UpliftApprovalsEmptyState tab={tab} />
</TableCell>
</TableRow>
);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
export function UpliftApprovalsTable({
tab,
isLoading,
hasError,
rows,
canRevoke,
onOpenDetail,
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
hasError: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small" aria-label={`${tab} uplift requests`}>
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={headerCells.length} align="center" className="py-10">
{tab === "pending" ? (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
) : (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
)}
</TableCell>
</TableRow>
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
) : (
rows.map((row) => (
<UpliftApprovalRow

View file

@ -0,0 +1,28 @@
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
import { Box, TableCell } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
return (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell sx={{ maxWidth: 260 }}>
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote || "—"}
</Text>
</TableCell>
</>
);
}
export function RejectedEmptyState() {
return (
<Box className="flex flex-col items-center gap-2">
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
<Text variant="description">No rejected uplifts.</Text>
</Box>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import {
UpliftApprovalsTable,
UPLIFT_APPROVAL_TABS,
isUpliftApprovalTab,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import {
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { Text } from "@/components/ui/text";
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
const PAGE_SIZE = 25;
@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
);
}
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
};
// A tab's total is only known once its queue has loaded at least once; until then the
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
// A failed refetch keeps the last loaded page, so its total stays usable.
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
return query.data ? query.data.totalCount : null;
}
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
return (
<Chip
size="small"
label={count ?? "—"}
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
/>
);
}
function UpliftApprovalsTabs({
tab,
pendingCount,
counts,
onChange,
}: {
tab: UpliftApprovalTab;
pendingCount: number;
counts: Record<UpliftApprovalTab, number | null>;
onChange: (_event: unknown, value: string) => void;
}) {
return (
<Tabs value={tab} onChange={onChange}>
{UPLIFT_APPROVAL_TABS.map((value) => (
<Tab
value="pending"
key={value}
value={value}
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
<span>{TAB_LABELS[value]}</span>
<TabCountPill tab={value} count={counts[value]} />
</Stack>
}
/>
<Tab value="approved" label="Approved" />
))}
</Tabs>
);
}
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
page,
onPageChange,
}: {
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
page: number;
onPageChange: (page: number) => void;
}) {
return (
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
page={page}
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
export default function UpliftQueuePage() {
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
pending: 0,
approved: 0,
rejected: 0,
});
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
page: pages.pending + 1,
pageSize: PAGE_SIZE,
});
const approvedQuery = useUpliftsQueue({
status: "Approved",
page: approvedPage + 1,
page: pages.approved + 1,
pageSize: PAGE_SIZE,
});
const rejectedQuery = useUpliftsQueue({
status: "Rejected",
page: pages.rejected + 1,
pageSize: PAGE_SIZE,
});
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
const controller = useUpliftApprovalController();
const {
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
openDetail,
} = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const activeQuery = queries[tab];
const rows = activeQuery.data?.items ?? [];
const pendingCount = pendingQuery.data?.totalCount ?? 0;
const counts = {
pending: knownTotal(pendingQuery),
approved: knownTotal(approvedQuery),
rejected: knownTotal(rejectedQuery),
};
const activeTotal = counts[tab];
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
const handleTabChange = (_event: unknown, value: string) => {
if (value === "pending" || value === "approved") {
if (isUpliftApprovalTab(value)) {
setTab(value);
}
};
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
return (
<Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
hasError={Boolean(activeQuery.error)}
rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
{activeTotal != null && (
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
count={activeTotal}
page={pages[tab]}
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
/>
)}
{detailItem != null && (
<UpliftDetailModal
item={detailItem}

View file

@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import {
PAGE_BOTTOM_BAR_PX,
PAGE_BOTTOM_PADDING_PX,
PAGE_GAP_PX,
PAGE_TOP_BAR_PX,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
import { useCompDocPagination } from "./use-comp-doc-pagination";
type CompDocDocumentFrameProps = {
/** Letterhead and opening content, printed once at the top of page 1. */
header: ReactNode;
/** Blocks (procedures, comments) laid out across pages; never split. */
sections: readonly ReactNode[];
/** Greyed-out pages, as for a document marked not required. */
dimmed?: boolean;
/** Stamped over the first sheet, outside the dimmed layer. */
overlay?: ReactNode;
/** Page being read and page count, updated on layout and on scroll. */
onPageChange?: (position: PagePosition) => void;
};
/** Contains child margins so a block's measured height is its full height. */
const flowRoot = { display: "flow-root" } as const;
/**
* The printed document: A4 sheets stacked in a scrollable column, the letterhead
* on the first sheet only and the brand bars at the top and bottom of every sheet.
*/
export function CompDocDocumentFrame({
header,
sections,
dimmed = false,
overlay,
onPageChange,
}: CompDocDocumentFrameProps) {
const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } =
useCompDocPagination(sections.length, onPageChange);
return (
<div
ref={scrollerRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
data-testid="comp-doc-document"
>
<div ref={sheetsRef} className="relative">
{pages.map((blockIndices, pageIndex) => (
<div
key={pageIndex}
ref={(el) => {
pageRefs.current[pageIndex] = el;
}}
data-testid="comp-doc-page"
className="relative flex flex-col break-words"
style={{
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
minHeight: pageHeight,
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
className="flex flex-1 flex-col"
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
data-testid="comp-doc-page-top-bar"
style={{
height: PAGE_TOP_BAR_PX,
flexShrink: 0,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
{pageIndex === 0 && (
<div ref={headerRef} style={flowRoot}>
{header}
</div>
)}
{blockIndices.map((blockIndex) => (
<div
key={blockIndex}
ref={(el) => {
blockRefs.current[blockIndex] = el;
}}
style={flowRoot}
>
{sections[blockIndex]}
</div>
))}
</div>
<div
data-testid="comp-doc-page-bottom-bar"
style={{
height: PAGE_BOTTOM_BAR_PX,
flexShrink: 0,
marginTop: "auto",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{pageIndex === 0 && overlay}
</div>
))}
</div>
</div>
);
}

View file

@ -1,10 +1,15 @@
import { CheckCircle2, FileText } from "lucide-react";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
type CompDocPreviewBadgeProps = {
isGenerated: boolean;
page: PagePosition;
};
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
@ -26,6 +31,7 @@ export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
{pageIndicatorLabel(page)}
</span>
</div>
);

View file

@ -1,46 +1,20 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
notes: string;
};
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
/** The work order's procedure: heading, intro and steps, kept together on one page. */
export function CompDocPreviewProcedure({ row }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
<Text
variant="body"
sx={{
@ -53,21 +27,15 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
</>
);
}
{notes.length > 0 && (
/** The PM's free-text comments printed after the procedure. */
export function CompDocPreviewComments({ notes }: { notes: string }) {
return (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{
@ -81,7 +49,5 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{notes}
</Text>
</>
)}
</>
);
}

View file

@ -1,10 +1,14 @@
import { useState } from "react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { PagePosition } from "@/domain/work-orders/utils/completion-doc-pagination";
import { CompDocDocumentFrame } from "./comp-doc-document-frame";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewComments, CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@ -23,6 +27,10 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const sections = [<CompDocPreviewProcedure key="procedure" row={row} />];
if (notes.length > 0) sections.push(<CompDocPreviewComments key="notes" notes={notes} />);
return (
<div
className="flex min-h-0 flex-col"
@ -32,37 +40,14 @@ export function CompDocPreview({
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewBadge isGenerated={isGenerated} />
<CompDocPreviewBadge isGenerated={isGenerated} page={page} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
onPageChange={setPage}
header={
<>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
@ -70,20 +55,11 @@ export function CompDocPreview({
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
<CompDocSafetyBullets />
</>
}
sections={sections}
/>
</div>
{isNotRequired && <CompDocNotRequiredStamp />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
export function CompDocProcedureHeading({ children }: { children: ReactNode }) {
return (
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{children}
</Text>
);
}

View file

@ -0,0 +1,27 @@
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocSafetyBulletsProps = {
/** Template-specific notes printed after the standard bullets. */
extra?: readonly string[];
};
export function CompDocSafetyBullets({ extra = [] }: CompDocSafetyBulletsProps) {
return (
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{[...SAFETY_BULLETS, ...extra].map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
whiteSpace: i < SAFETY_BULLETS.length ? undefined : "pre-wrap",
}}
>
{b}
</li>
))}
</ul>
);
}

View file

@ -0,0 +1,112 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
a4PageHeight,
coverAllBlocks,
currentPageAt,
pageContentHeight,
paginateBlocks,
samePages,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
type PageLayout = {
pages: number[][];
/** Sheet height in px; undefined until the sheets have a measurable width. */
pageHeight: number | undefined;
};
const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0;
function allOnOnePage(count: number): number[][] {
return [Array.from({ length: count }, (_, i) => i)];
}
/**
* Measures the rendered letterhead and blocks and splits them into A4 sheets.
* Blocks are measured where they are rendered (every sheet has the same width),
* and re-measured after every render and whenever anything observed resizes.
*/
export function useCompDocPagination(
blockCount: number,
onPageChange?: (position: PagePosition) => void,
) {
const scrollerRef = useRef<HTMLDivElement>(null);
const sheetsRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
const [layout, setLayout] = useState<PageLayout>({ pages: [[]], pageHeight: undefined });
const pages = coverAllBlocks(layout.pages, blockCount);
const measure = useCallback(() => {
const width = sheetsRef.current?.getBoundingClientRect().width ?? 0;
const next: PageLayout =
width > 0
? {
pages: paginateBlocks({
headerHeight: heightOf(headerRef.current),
blockHeights: Array.from({ length: blockCount }, (_, i) =>
heightOf(blockRefs.current[i]),
),
pageHeight: pageContentHeight(width),
}),
pageHeight: a4PageHeight(width),
}
: { pages: allOnOnePage(blockCount), pageHeight: undefined };
setLayout((prev) =>
samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next,
);
}, [blockCount]);
// Block content can change on any render (typing in the editor), so measure after each one.
useLayoutEffect(() => {
measure();
});
const onPageChangeRef = useRef(onPageChange);
const totalRef = useRef(pages.length);
const lastReported = useRef<PagePosition | null>(null);
useLayoutEffect(() => {
onPageChangeRef.current = onPageChange;
totalRef.current = pages.length;
});
const report = useCallback(() => {
const total = totalRef.current;
const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
const last = lastReported.current;
if (last?.current === current && last.total === total) return;
lastReported.current = { current, total };
onPageChangeRef.current?.({ current, total });
}, []);
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
useLayoutEffect(() => {
report();
});
// Width changes, late font/image loads and reflow do not re-render; observe them.
const layoutKey = pages.map((page) => page.join(",")).join("|");
useEffect(() => {
const observer = new ResizeObserver(() => {
measure();
report();
});
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
(el) => el && observer.observe(el),
);
return () => observer.disconnect();
}, [measure, report, blockCount, layoutKey]);
return {
pages,
pageHeight: layout.pageHeight,
scrollerRef,
sheetsRef,
headerRef,
blockRefs,
pageRefs,
onScroll: report,
};
}

View file

@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>

View file

@ -1,5 +1,5 @@
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -40,6 +40,5 @@ export function resolveLocationId(
}
export function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
return buildSiteMapsUrl(location);
}

View file

@ -7,9 +7,11 @@ import {
type UpliftCellProps = {
summary: UpliftSummary;
onOpen?: () => void;
/** Closed work orders show a dash (not "Manage") that still opens the read-only history. */
readOnly?: boolean;
};
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
export function UpliftCell({ summary, onOpen, readOnly = false }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
if (!onOpen) {
@ -30,7 +32,7 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
e.stopPropagation();
onOpen();
}}
title={label ? "View uplifts" : "Manage uplifts"}
title={label || readOnly ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
>
{label && pillStyle ? (
@ -40,6 +42,8 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
>
{label}
</span>
) : readOnly ? (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}

View file

@ -9,7 +9,7 @@ import { StatusCell } from "./cells/status-cell";
import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
import type { WoTableRowHandlers } from "./wo-table-row";
@ -132,12 +132,8 @@ export function WoTableRowServiceCells({
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
onOpen={
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
handlers.onOpenUplifts
? () => handlers.onOpenUplifts!(row)
: undefined
}
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
/>
</td>
</>

View file

@ -12,6 +12,7 @@ type WorkOrderUpliftListItemProps = {
uplift: WorkOrderUplift;
readOnly: boolean;
currentUserId: string | number | null | undefined;
currentUserIsAdmin: boolean;
pendingAction?: boolean;
onCancel?: () => void;
onRevoke?: () => void;
@ -21,6 +22,7 @@ export function WorkOrderUpliftListItem({
uplift,
readOnly,
currentUserId,
currentUserIsAdmin,
pendingAction = false,
onCancel,
onRevoke,
@ -28,7 +30,9 @@ export function WorkOrderUpliftListItem({
const pillStyle = getUpliftStatusPillStyle(uplift.status);
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
!readOnly &&
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
Boolean(onRevoke);
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">

View file

@ -12,6 +12,7 @@ import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-displa
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
currentUserId: string | number | null | undefined;
currentUserIsAdmin: boolean;
readOnlyStatusLabel?: string;
isLoading: boolean;
error: Error | null;
@ -28,6 +29,7 @@ type WorkOrderUpliftsDialogContentProps = {
export function WorkOrderUpliftsDialogContent({
readOnly,
currentUserId,
currentUserIsAdmin,
readOnlyStatusLabel,
isLoading,
error,
@ -77,6 +79,7 @@ export function WorkOrderUpliftsDialogContent({
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
currentUserIsAdmin={currentUserIsAdmin}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}

View file

@ -15,6 +15,7 @@ import {
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
@ -78,6 +79,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
currentUserIsAdmin={isAdminUser(user?.userRoles)}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}

View file

@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
type WizardStepLocationSiteDetailsProps = {
selectedLoc: LocationOption | undefined;
locationName: string;
@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
href={buildSiteMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"

View file

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

View file

@ -0,0 +1,100 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import {
inviteCodeStepSchema,
type InviteCodeStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import { InviteStepHeader } from "./invite-step-header";
export type InviteCodeStepProps = {
email: string;
isVerifying: boolean;
isResending: boolean;
/** Why the entered code was refused; shown under the field. */
codeError: string | null;
/** Why a new code could not be sent. */
resendError: string | null;
onVerify: (code: string) => void;
onResend: () => void;
onCodeChange: () => void;
};
export function InviteCodeStep({
email,
isVerifying,
isResending,
codeError,
resendError,
onVerify,
onResend,
onCodeChange,
}: InviteCodeStepProps) {
const { register, handleSubmit, control } = useForm<InviteCodeStepValues>({
resolver: zodResolver(inviteCodeStepSchema),
defaultValues: { code: "" },
});
const values = useWatch({ control });
const canContinue = inviteCodeStepSchema.safeParse(values).success;
const codeField = register("code", { onChange: onCodeChange });
return (
<Box
component="form"
aria-label="Confirm your email"
onSubmit={handleSubmit((form) => onVerify(form.code))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={2}
title="Confirm your email"
subtitle={`Enter the 6-digit code sent to ${email}`}
/>
<FormField
label="Confirmation code"
htmlFor="invite-code"
required
appearance="auth"
error={codeError !== null}
helperText={codeError ?? undefined}
>
<TextField
{...codeField}
id="invite-code"
placeholder="123456"
autoComplete="one-time-code"
error={codeError !== null}
slotProps={{ htmlInput: { inputMode: "numeric", maxLength: 6 } }}
/>
</FormField>
<div className="flex flex-col items-start gap-1">
<Button type="button" variant="link" onClick={onResend} disabled={isResending}>
Resend code
</Button>
<Text variant="error" when={resendError !== null}>
{resendError}
</Text>
</div>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isVerifying}
loadingText="Checking code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -0,0 +1,101 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { PasswordChecklist } from "@/components/auth/password-checklist";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import {
invitePasswordStepSchema,
type InvitePasswordStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import { InviteStepHeader } from "./invite-step-header";
export type InvitePasswordStepProps = {
name: string;
defaultPassword: string;
isSubmitting: boolean;
error: string | null;
onContinue: (password: string) => void;
};
export function InvitePasswordStep({
name,
defaultPassword,
isSubmitting,
error,
onContinue,
}: InvitePasswordStepProps) {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<InvitePasswordStepValues>({
resolver: zodResolver(invitePasswordStepSchema),
defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
mode: "onTouched",
});
const values = useWatch({ control });
const canContinue = invitePasswordStepSchema.safeParse(values).success;
return (
<Box
component="form"
aria-label="Create your password"
onSubmit={handleSubmit((form) => onContinue(form.password))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={1}
title="Create your password"
subtitle={`Setting up the account for ${name}`}
/>
<FormField label="Password" htmlFor="invite-password" required appearance="auth">
<PasswordField
{...register("password")}
id="invite-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "invite-password-rules" } }}
/>
<PasswordChecklist id="invite-password-rules" password={values.password ?? ""} />
</FormField>
<FormField
label="Confirm password"
htmlFor="invite-confirm-password"
required
appearance="auth"
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="invite-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isSubmitting}
loadingText="Sending code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -0,0 +1,100 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { CircleCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import {
inviteReviewStepSchema,
type InviteReviewStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
import { InviteStepHeader } from "./invite-step-header";
export type InviteReviewStepProps = {
details: TeamMemberInviteDetails;
isFinishing: boolean;
error: string | null;
onFinish: (phone: string) => void;
};
function ReadOnlyValue({ label, value }: { label: string; value: string }) {
return (
<div>
<Text variant="label" className="block">
{label}
</Text>
<Text variant="body" className="m-0 text-(length:--text-base-sm)">
{value}
</Text>
</div>
);
}
export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InviteReviewStepValues>({
resolver: zodResolver(inviteReviewStepSchema),
defaultValues: { phone: details.phone ?? "" },
mode: "onTouched",
});
return (
<Box
component="form"
aria-label="Review your info"
onSubmit={handleSubmit((form) => onFinish(form.phone))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={3}
title="Review your info"
subtitle="Name, role, and email are set by your admin — contact them to change these."
/>
<div className="grid gap-3">
<ReadOnlyValue label="Name" value={details.name} />
<ReadOnlyValue label="Role" value={details.role} />
<ReadOnlyValue label="Email" value={details.email} />
<FormField
label="Phone"
htmlFor="invite-phone"
appearance="auth"
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
>
<TextField
{...register("phone")}
id="invite-phone"
type="tel"
autoComplete="tel"
placeholder="(000) 000-0000"
error={Boolean(errors.phone)}
/>
</FormField>
</div>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
loading={isFinishing}
loadingText="Finishing…"
startIcon={<CircleCheck size={15} aria-hidden="true" />}
>
Finish registration
</Button>
</Box>
);
}

View file

@ -0,0 +1,46 @@
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export const INVITE_STEP_COUNT = 3;
export type InviteStep = 1 | 2 | 3;
export type InviteStepHeaderProps = {
step: InviteStep;
title: string;
subtitle: string;
};
export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) {
return (
<div className="mb-5">
<div
role="progressbar"
aria-label="Registration progress"
aria-valuemin={1}
aria-valuemax={INVITE_STEP_COUNT}
aria-valuenow={step}
className="mb-5 flex items-center gap-1.5"
>
{[1, 2, 3].map((bar) => (
<span
key={bar}
className={cn(
"h-[3px] flex-1 rounded-sm",
bar <= step ? "bg-(--color-primary)" : "bg-(--color-border)",
)}
/>
))}
</div>
<Text variant="caption" tone="muted" className="block">
{`Step ${step} of ${INVITE_STEP_COUNT}`}
</Text>
<Text variant="title" className="m-0 mt-1! ![font-size:var(--text-lg)] !font-semibold">
{title}
</Text>
<Text variant="description" tone="muted" className="mt-1!">
{subtitle}
</Text>
</div>
);
}

View file

@ -0,0 +1,137 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
import {
useCompleteRegistration,
useSendInviteCode,
useVerifyInviteCode,
} from "@/domain/team-member-invite/use-cases/use-team-member-invite";
import { InviteCodeStep } from "./invite-code-step";
import { InvitePasswordStep } from "./invite-password-step";
import { InviteReviewStep } from "./invite-review-step";
import type { InviteStep } from "./invite-step-header";
export type InviteWizardProps = {
token: string;
details: TeamMemberInviteDetails;
/** Called when the server reports the invite can no longer be used. */
onInviteInvalid: () => void;
};
export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) {
const [step, setStep] = useState<InviteStep>(1);
const [password, setPassword] = useState("");
const [stepError, setStepError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [resendError, setResendError] = useState<string | null>(null);
const sendCode = useSendInviteCode();
const verifyCode = useVerifyInviteCode();
const complete = useCompleteRegistration();
/** Returns true when the failure ended the invite and the wizard should stop. */
const endedInvite = (error: Error) => {
if (inviteErrorCode(error) !== "invalid_invite") {
return false;
}
onInviteInvalid();
return true;
};
const handlePasswordContinue = (nextPassword: string) => {
setPassword(nextPassword);
setStepError(null);
sendCode.mutate(token, {
onSuccess: () => setStep(2),
onError: (error) => {
if (endedInvite(error)) return;
// A code sent moments ago is still valid, so the member can go on and enter it.
if (inviteErrorCode(error) === "resend_too_soon") {
setStep(2);
return;
}
setStepError(error.message);
},
});
};
const handleResend = () => {
setResendError(null);
sendCode.mutate(token, {
onSuccess: () => {
setCodeError(null);
toast.success(`Code resent to ${details.email}`);
},
onError: (error) => {
if (!endedInvite(error)) setResendError(error.message);
},
});
};
const handleVerify = (code: string) => {
setCodeError(null);
verifyCode.mutate(
{ token, code },
{
onSuccess: () => setStep(3),
onError: (error) => {
if (!endedInvite(error)) setCodeError(error.message);
},
},
);
};
const handleFinish = (phone: string) => {
setStepError(null);
complete.mutate(
{ token, password, phone },
{
onError: (error) => {
if (endedInvite(error)) return;
if (inviteErrorCode(error) === "password_rejected") {
setStep(1);
}
setStepError(error.message);
},
},
);
};
if (step === 1) {
return (
<InvitePasswordStep
name={details.name}
defaultPassword={password}
isSubmitting={sendCode.isPending}
error={stepError}
onContinue={handlePasswordContinue}
/>
);
}
if (step === 2) {
return (
<InviteCodeStep
email={details.email}
isVerifying={verifyCode.isPending}
isResending={sendCode.isPending}
codeError={codeError}
resendError={resendError}
onVerify={handleVerify}
onResend={handleResend}
onCodeChange={() => setCodeError(null)}
/>
);
}
return (
<InviteReviewStep
details={details}
isFinishing={complete.isPending}
error={stepError}
onFinish={handleFinish}
/>
);
}

87
src/app/invite/index.tsx Normal file
View file

@ -0,0 +1,87 @@
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, CircularProgress } from "@mui/material";
import { ChevronLeft } from "lucide-react";
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { Button } from "@/components/ui/button";
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
import {
INVALID_INVITE_MESSAGE,
INVITE_UNREACHABLE_MESSAGE,
} from "@/domain/team-member-invite/types/team-member-invite";
import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite";
import { InviteWizard } from "./_components/invite-wizard";
/** The invite email links to `/invite#<token>`; a fragment never reaches a server or a Referer. */
function readInviteToken(hash: string): string {
return hash.replace(/^#/, "").trim();
}
function InviteBody({ token }: { token: string }) {
const details = useTeamMemberInviteDetails(token);
const [inviteEnded, setInviteEnded] = useState(false);
if (token.length > 0 && details.isPending) {
return (
<Box className="flex justify-center py-8" role="status" aria-label="Loading invite">
<CircularProgress />
</Box>
);
}
// Only the server's own `invalid_invite` answer means the link is dead. A request that never
// got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry
// instead of sending the member to their admin for a new invite.
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
return (
<Box>
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
<Button
type="button"
fullWidth
size="lg"
loading={details.isFetching}
loadingText="Trying again…"
onClick={() => void details.refetch()}
>
Try again
</Button>
</Box>
);
}
if (inviteEnded || details.data === undefined) {
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
}
return (
<InviteWizard
token={token}
details={details.data}
onInviteInvalid={() => setInviteEnded(true)}
/>
);
}
export default function InviteRegistrationPage() {
const { hash } = useLocation();
const token = readInviteToken(hash);
return (
<AuthPageShell>
<AuthBrandLogo className="mb-8" />
<Link
to="/login"
className="mb-6 inline-flex items-center gap-1 text-(length:--text-xs) text-(--color-text-muted) no-underline hover:text-foreground"
>
<ChevronLeft size={13} aria-hidden="true" />
Back to login
</Link>
<InviteBody token={token} />
</AuthPageShell>
);
}

View file

@ -22,10 +22,12 @@ export function ChecklistSection({
const toggle = (item: VendorPortalChecklistItem) => {
if (locked) return;
updateItem.mutate(
{ itemId: item.id, isCompleted: !item.isCompleted },
{ onSuccess: (updated) => onItemUpdated(updated) },
);
// mutateAsync resolves per call, unlike mutate's onSuccess which only fires for the
// latest call on this shared observer — needed so two quick toggles both report back.
void updateItem
.mutateAsync({ itemId: item.id, isCompleted: !item.isCompleted })
.then((updated) => onItemUpdated(updated))
.catch(() => undefined);
};
if (!items.length) {

View file

@ -0,0 +1,63 @@
import { useState } from "react";
import { Circle, CircleCheck } from "lucide-react";
import { Text } from "@/components/ui/text";
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
import { cn } from "@/lib/utils";
export type PasswordChecklistProps = {
password: string;
id?: string;
className?: string;
};
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
return next
.filter((rule, index) => rule.met !== previous[index]?.met)
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
.join(". ");
}
/**
* Live, per-rule feedback for the shared password policy. A polite status region
* announces only the rules whose state changed, so screen-reader users hear
* progress while typing without the whole list being re-read on each keystroke.
*/
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
const results = evaluatePasswordRules(password);
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
const [previous, setPrevious] = useState({ signature, results });
const [announcement, setAnnouncement] = useState("");
if (previous.signature !== signature) {
setPrevious({ signature, results });
setAnnouncement(describeChanges(previous.results, results));
}
return (
<>
<ul
id={id}
aria-label="Password requirements"
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
>
{results.map(({ id: ruleId, label, met }) => (
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
{met ? (
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
) : (
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
)}
<Text variant="caption" tone={met ? "success" : "muted"}>
{label}
</Text>
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
</li>
))}
</ul>
<Text variant="feedback" className="sr-only">
{announcement}
</Text>
</>
);
}

View file

@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
label?: string;
placeholder?: string;
required?: boolean;
/**
* Show MUI's required asterisk on the label. Sites marks required fields that
* way; Vendors spells "(required)" in the label and asserts no asterisk.
*/
requiredMarker?: boolean;
error?: boolean;
helperText?: string;
disabled?: boolean;
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
label = "Street Address",
placeholder = "Start typing the street address…",
required = false,
requiredMarker = false,
error = false,
helperText,
disabled = false,
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
error={error}
helperText={helperText}
fullWidth
// Required is set on the input itself, never via MUI's `required`
// prop: this codebase spells the requirement in the label text and
// asserts that no generated asterisk renders anywhere.
required={requiredMarker}
// Required is set on the input itself; MUI's `required` prop only
// adds the label asterisk, which Vendors must not render.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },

View file

@ -1,11 +1,14 @@
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { CircleUserRound } from "lucide-react";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { useNavGroups } from "@/components/layout/use-nav-groups";
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
const ACCOUNT_PATH = "/account";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
return {
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
</Box>
))}
</Box>
<Box
component="nav"
aria-label="Account navigation"
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
>
<NavItem
label="My Account"
icon={CircleUserRound}
path={ACCOUNT_PATH}
collapsed={collapsed}
active={pathname === ACCOUNT_PATH}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
</Box>
</Drawer>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -63,7 +63,10 @@ export const navGroups: NavGroup[] = [
},
{
title: "DATA MANAGEMENT",
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
items: [
{ label: "Services", icon: Wrench, path: "/settings/services" },
{ label: "Completion Docs", icon: FileText, path: "/completion-docs" },
],
},
{
title: "TEAM",

View file

@ -40,12 +40,14 @@ export async function changePassword(data: ChangePasswordPayload): Promise<unkno
return apiPost(API_PATHS.authentication.changePassword, data);
}
// Email and code travel in the JSON body so they stay out of URLs, traces and proxy logs.
export async function forgotPassword(email: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`);
return apiPost(API_PATHS.authentication.forgetPassword, { email });
}
export async function verifyResetCode(code: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`);
// The server checks a code only against the email it was issued to.
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
}
export async function resetPassword(data: ResetPasswordPayload): Promise<unknown> {

View file

@ -0,0 +1,17 @@
import { z } from "zod";
import { apiGet } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
export const UNEXPECTED_PERMISSIONS_RESPONSE =
"The server sent an unexpected permissions response.";
const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) });
/** Keys the signed-in user holds after role defaults and their individual overrides. */
export async function getMyPermissions(): Promise<string[]> {
const data = await apiGet<unknown>(API_PATHS.teamMember.myPermissions);
const result = effectivePermissionsSchema.safeParse(handleApiResponse<unknown>(data));
if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE);
return result.data.permissions;
}

View file

@ -0,0 +1,53 @@
import { z } from "zod";
/**
* The single password rule for every surface that sets a password. It mirrors the
* server's Identity options: the character classes are ASCII, matching how the
* server classifies uppercase letters, digits and non-alphanumeric characters.
*/
export const PASSWORD_MIN_LENGTH = 6;
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
export type PasswordRule = {
id: PasswordRuleId;
label: string;
test: (password: string) => boolean;
};
export type PasswordRuleResult = {
id: PasswordRuleId;
label: string;
met: boolean;
};
export const PASSWORD_RULES: readonly PasswordRule[] = [
{
id: "length",
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
},
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
{
id: "special",
label: "One special character",
test: (password) => /[^A-Za-z0-9]/.test(password),
},
];
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
}
export function meetsPasswordPolicy(password: string): boolean {
return PASSWORD_RULES.every((rule) => rule.test(password));
}
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
message: PASSWORD_REQUIREMENTS_MESSAGE,
});

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