Compare commits

...

80 commits

Author SHA1 Message Date
Arthur Bassi
f2ece0f248
Merge 52934dc959 into 6a6d519ed7 2026-09-28 17:24:01 +00:00
Arthur Bassi
52934dc959 fix(work-orders): use the saved work order type for uplift allowance
The create API uses the saved type, so an unsaved draft must not change the allowance.
2026-09-28 14:23:11 -03:00
Arthur Bassi
94aa7a1d66 Merge remote-tracking branch 'origin/main' into feat/SH-388-uplift-slide-over
# Conflicts:
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx
#	src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
#	src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx
#	src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx
#	src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx
#	src/components/common/address-autocomplete-field.tsx
#	src/domain/work-orders/utils/uplift-display-utils.ts
#	src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
2026-09-28 14:08:51 -03:00
Arthur Bassi
fd561aa578 fix(work-orders): keep in-flight uplift requests on the slide-over
Point notification deep links at the Uplifts tab and follow the unsaved
Info draft. Block close while an evidence scan is still running.
2026-09-28 12:51:28 -03: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
Alexandre Brandizzi
358ec3e890
Merge pull request #261 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-392: Unassigned KPI tile on the Dashboard
2026-09-25 06:52:00 +00:00
Alexandre Brandizzi
b91682f53c Merge remote-tracking branch 'origin/main' into lane/pr261 2026-09-25 03:02:25 -03:00
Alexandre Brandizzi
8bb4c0c512 fix(dashboard): open the Unassigned tile as the Unassigned filter option
The tile link sent the board API token __unassigned__ as the dispatcher.
The drill-down parser stores that as an Advanced Filters dispatcher id,
while the filter UI only knows __unassigned, so the banner showed a raw
__unassigned__ chip, Assigned to did not tick Unassigned, and the option
could not clear it. Send ASSIGNEE_FILTER_UNASSIGNED, as the notification
queue link already does; toBoardSearchQueryParams still maps it to
__unassigned__ for /board/search, so the rows are unchanged.
2026-09-24 22:45:10 -03:00
Alexandre Brandizzi
67cb490d52 test(dashboard): refresh visual baselines for the Unassigned tile and the Services entry
Rendered by the CI Playwright container at f64447f5.
2026-09-24 22:20:14 -03:00
Alexandre Brandizzi
f64447f52c chore(dashboard): keep ticket keys out of source comments 2026-09-24 22:06:24 -03:00
Alexandre Brandizzi
7984640a5f Merge remote-tracking branch 'origin/fix/ab/sh-391-adv-filter-date-range' into feat/ab/sh-392-dashboard-unassigned 2026-09-24 22:05:33 -03:00
Alexandre Brandizzi
792dc6917b Merge remote-tracking branch 'origin/main' into feat/ab/sh-392-dashboard-unassigned
# Conflicts:
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
2026-09-24 22:05:10 -03:00
Alexandre Brandizzi
60b0638ea3 test(dashboard): update visual baselines for the Unassigned tile (SH-392)
Rendered by the CI Playwright container (mcr.microsoft.com/playwright:v1.61.1-noble)
at fca87b67.
2026-09-24 21:52:21 -03:00
Alexandre Brandizzi
fca87b67cb test(dashboard): give the visual fixture an Unassigned count (SH-392) 2026-09-24 21:48:16 -03:00
Alexandre Brandizzi
ed931ea2eb SH-392: Unassigned KPI tile on the Dashboard
Shows open work orders with no dispatcher for the selected period and
drills into the same list on the board (dispatcher Unassigned, open
statuses, same dates; All time includes undated work orders).
2026-09-24 21:34:35 -03:00
Arthur Bassi
3c5941b895 chore(work-orders): resolve SH-388 uplift conflicts onto current main
Keep the slide-over uplift tab and the evidence upload while taking the
current board, dashboard, and vendor changes from main.
2026-09-24 15:17:34 -03:00
Arthur Bassi
32466c01d2 fix(work-orders): open uplifts from the slide-over and send evidence (SH-388)
Dispatchers need the design surface from the column and the detail,
and an optional evidence file is sent only after the scan passes.
2026-09-24 14:30:24 -03:00
302 changed files with 11390 additions and 1718 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: 143 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 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: 144 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

@ -191,7 +191,10 @@ async function mockDashboardApi(page: Page, userRoles: string) {
return;
}
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
if (pathname.includes("/Dashboard/Stats")) {
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
return fulfillJson(route, { data: { ...STATS, unassigned } });
}
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
if (pathname.includes("/Dashboard/Performance"))
return fulfillJson(route, { data: PERFORMANCE });

View file

@ -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}`,
@ -43,6 +46,9 @@ export const API_PATHS = {
`workorders/${id}/media/${mediaId}/content`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftEvidence: (id: string | number) => `workorders/${id}/uplift-evidence`,
upliftEvidenceStatus: (workOrderId: string | number, documentId: string | number) =>
`workorders/${workOrderId}/uplift-evidence/${documentId}`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
@ -83,6 +89,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,71 @@
import { useNavigate } from "react-router";
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
unassignedDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
interface DashboardKpiCardsProps {
stats?: DashboardStats;
loading: boolean;
range: DashboardRangeKey;
}
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
const navigate = useNavigate();
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
const pendingUplifts = stats?.pendingUplifts ?? 0;
const avetaPending = stats?.avetaPending ?? 0;
const unassigned = stats?.unassigned ?? 0;
return (
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats?.scheduledTomorrow}
loading={loading}
active={scheduledTomorrow > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats?.pendingUplifts}
loading={loading}
active={pendingUplifts > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats?.avetaPending}
loading={loading}
active={avetaPending > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
<DashboardKpiCard
title="Unassigned"
icon={<AssignmentLateOutlinedIcon />}
tone="warning"
value={stats?.unassigned}
loading={loading}
active={unassigned > 0}
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
/>
</Box>
);
}

View file

@ -27,7 +27,7 @@ function formatDays(value: number): string {
return (Math.round(value * 10) / 10).toLocaleString();
}
// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
// why: SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
function completionRateColor(rate: number): string {
if (rate >= 90) return "success.main";
if (rate >= 70) return "warning.main";
@ -42,12 +42,10 @@ function DispatcherNameCell({
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
// A listed dispatcher is one with work in range; the drill-down filters Work
// Orders by dispatcher + range and never uses the completion rate, so gate on
// identity rather than the rate. (A dispatcher with assigned work but a 0%
// rate was previously inert here while drillable from Workload.) The gate stays
// on identity rather than `assignedCount > 0` so a legacy response without the
// SH-347 count field (assignedCount → 0) keeps its rows drillable.
// why: A listed dispatcher is one with work in range; the drill-down filters Work Orders by dispatcher
// + range and never uses the completion rate, so gate on identity rather than the rate. (A
// dispatcher with assigned work but a 0% rate was previously inert here while drillable from
// Workload.) The gate stays on identity rather than `assignedCount > 0` so a legacy response without the SH-347 count field (assignedCount → 0) keeps its rows drillable.
const drillable = Boolean(row.dispatcherId);
if (!drillable) {

View file

@ -43,8 +43,8 @@ export function StatusDistributionCard({
label: row.status,
count: row.count,
}))}
// Lifecycle labels the board can filter on are drillable; the "Unknown"
// bucket (unparseable statuses) has no board filter, so leave it inert.
// why: Lifecycle labels the board can filter on are drillable; the "Unknown" bucket (unparseable
// statuses) has no board filter, so leave it inert.
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))

View file

@ -40,9 +40,9 @@ export function useDashboardScope(): DashboardScopeState {
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
// A range or scope change re-scopes both dispatcher tables, so their page must
// reset — otherwise a switch while on page 3 lands on an out-of-range empty
// page that reads as a false "no dispatchers" state.
// why: A range or scope change re-scopes both dispatcher tables, so their page must reset — otherwise a
// switch while on page 3 lands on an out-of-range empty page that reads as a false "no
// dispatchers" state.
const resetPages = () => {
setWorkloadPage(1);
setPerformancePage(1);
@ -64,8 +64,8 @@ export function useDashboardScope(): DashboardScopeState {
resetPages();
};
// A Dispatcher's forced own-WOs scope never triggers the reset affordance (only
// an active date range can, for that role); a Scheduler/Admin's non-All scope does.
// why: A Dispatcher's forced own-WOs scope never triggers the reset affordance (only an active date
// range can, for that role); a Scheduler/Admin's non-All scope does.
const scopeIsFiltered = canViewAll && scope.mode !== "all";
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;

View file

@ -28,9 +28,8 @@ export function WorkOrdersByRegionCard({
}: WorkOrdersByRegionCardProps) {
const navigate = useNavigate();
// SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet
// confirmed with the client ("do not implement until confirmed"), so only the
// four canonical zones are shown.
// why: SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet confirmed with the client
// ("do not implement until confirmed"), so only the four canonical zones are shown.
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
return (

View file

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

View file

@ -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,107 @@
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;
const countUnknown = isLoading || isFetching || isError;
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,109 @@
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) => {
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

@ -13,8 +13,8 @@ export function openUpliftEvidence(row: UpliftQueueItem): void {
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
// why: Open the tab inside the click handler; a tab opened after the fetch resolves is treated as an
// unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;

View file

@ -0,0 +1,50 @@
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;
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,82 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
{tab === "pending" && (
<>
<TableCell>
<WaitingCell row={row} />
</TableCell>
<TableCell>
<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>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
{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>
);
}
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 +329,7 @@ export function UpliftApprovalsTable({
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
hasError: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
@ -315,7 +339,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 +360,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

@ -321,8 +321,8 @@ function RevokeModalAction({
}
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
// why: Permission failures (e.g. 403 for account-scoped users) are handled quietly: the breakdown falls
// back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});

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,68 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
);
}
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
};
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}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
{UPLIFT_APPROVAL_TABS.map((value) => (
<Tab
key={value}
value={value}
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>{TAB_LABELS[value]}</span>
<TabCountPill tab={value} count={counts[value]} />
</Stack>
}
/>
))}
</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 +121,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 +160,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 +179,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 +194,13 @@ export default function UpliftQueuePage() {
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{activeTotal != null && (
<UpliftApprovalsPagination
count={activeTotal}
page={pages[tab]}
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
/>
)}
{detailItem != null && (
<UpliftDetailModal
item={detailItem}

View file

@ -131,7 +131,7 @@ export function useVendorRosterForm({
);
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
const save = useSaveVendorCompanyRoster();
// Area assignment is Admin-only; the API enforces it and the field mirrors it.
// why: Area assignment is Admin-only; the API enforces it and the field mirrors it.
const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles);
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);

View file

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

View file

@ -119,9 +119,8 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
// SH-271 replaced the separate "Open in Google Maps" row: the Street Address
// itself is the link now, and the location is derived from the structured
// parts rather than a stored URL.
// why: SH-271 replaced the separate "Open in Google Maps" row: the Street Address itself is the link
// now, and the location is derived from the structured parts rather than a stored URL.
const address = { street: roster.address, city: roster.city, state: roster.state };
const mapsUrl = mapsSearchUrl(address);

View file

@ -167,9 +167,9 @@ function CompanyFields({
value={streetField.value ?? ""}
onInputChange={streetField.onChange}
onSelect={(parts) => {
// One pick fills all three. City and State are written through
// their own controllers so react-hook-form marks them dirty and
// revalidates — a plain assignment would be discarded on save.
// why: One pick fills all three. City and State are written through their own controllers so
// react-hook-form marks them dirty and revalidates — a plain assignment would be discarded on
// save.
streetField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? parts.state);

View file

@ -98,9 +98,9 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
// why: The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in one
// transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up Generate
// patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,

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,35 +27,27 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}
/** The PM's free-text comments printed after the procedure. */
export function CompDocPreviewComments({ notes }: { notes: string }) {
return (
<>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{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,58 +40,26 @@ 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)",
}}
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
onPageChange={setPage}
header={
<>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
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%)",
}}
/>
</div>
{isNotRequired && <CompDocNotRequiredStamp />}
</div>
</div>
<CompDocSafetyBullets />
</>
}
sections={sections}
/>
</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,109 @@
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]);
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 });
}, []);
useLayoutEffect(() => {
report();
});
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

@ -175,7 +175,7 @@ export function SlideOverCommentsTab({
);
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
const firstHighlightedRef = useRef<HTMLDivElement | null>(null);
// Bring the first comment a notification pointed at into view whenever the target changes — the
// why: Bring the first comment a notification pointed at into view whenever the target changes — the
// tab may already be mounted when a later notification re-opens this same work order.
useEffect(() => {
const node = firstHighlightedRef.current;

View file

@ -192,15 +192,12 @@ export function SlideOverExtrasTab({
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const busy = pending || screening;
const addFiles = async (files: FileList | null) => {
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);

View file

@ -0,0 +1,198 @@
import { useState, type ReactNode } from "react";
import { CircularProgress } from "@mui/material";
import { AlertTriangle, Ban } from "lucide-react";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { CancelUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
formatUpliftMoney,
getRemainingUpliftAllowance,
hasOpenWorkOrderUplift,
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 = {
id: string | number;
status: WorkOrderUpliftStatus;
amount: number;
};
type SlideOverUpliftsTabProps = {
workOrderId: string | number;
workOrderType: string;
status: WorkOrderTableRow["status"];
onCreatePendingChange?: (pending: boolean) => void;
};
function revokeDialogDescription(amount: number | undefined): string {
const money = amount == null ? "" : `${formatUpliftMoney(amount)} `;
return `${money}goes back to your auto-approval allowance. This is recorded in the log.`;
}
function UpliftNotice({
background,
icon,
children,
}: {
background: string;
icon: ReactNode;
children: string;
}) {
return (
<div
className="flex items-start gap-2"
style={{ background, borderRadius: "var(--radius)", padding: "10px 12px" }}
>
{icon}
<Text
as="span"
variant="description"
sx={{ fontSize: 12.5, color: "var(--color-text-secondary)" }}
>
{children}
</Text>
</div>
);
}
export function SlideOverUpliftsTab({
workOrderId,
workOrderType,
status,
onCreatePendingChange,
}: SlideOverUpliftsTabProps) {
const { user } = useAuthContext();
const readOnly = isWorkOrderUpliftsReadOnly(status);
const { data: uplifts = [], isLoading, error, refetch } = useWorkOrderUplifts(workOrderId);
const createUplift = useCreateWorkOrderUplift(workOrderId);
const cancelUplift = useCancelWorkOrderUplift(workOrderId);
const revokeUplift = useRevokeWorkOrderUplift(workOrderId);
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
const [cancelTargetId, setCancelTargetId] = useState<string | number | null>(null);
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
const showBody = !isLoading && !error;
const showEmpty = showBody && uplifts.length === 0;
const showForm = showBody && !readOnly && !hasOpenRequest;
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
return (
<div className="flex flex-col gap-4">
{readOnly && (
<UpliftNotice
background="var(--color-bg-muted)"
icon={
<Ban
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
>
This work order is closed. Uplifts can no longer be requested.
</UpliftNotice>
)}
{showBody && !readOnly && hasOpenRequest && (
<UpliftNotice
background="var(--status-pending-bg)"
icon={
<AlertTriangle
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--warning)" }}
/>
}
>
A request is pending admin approval. Cancel it if you need to submit a different amount.
</UpliftNotice>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={() => void refetch()}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{showForm && (
<WorkOrderUpliftCreateForm
pending={createUplift.isPending}
remainingAllowance={getRemainingUpliftAllowance(workOrderType, uplifts)}
onSubmit={async (input) => {
onCreatePendingChange?.(true);
try {
await createUplift.mutateAsync(input);
} finally {
onCreatePendingChange?.(false);
}
}}
/>
)}
{showBody &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={user?.id}
currentUserIsAdmin={isAdminUser(user?.userRoles)}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => setCancelTargetId(uplift.id)}
onRevoke={() =>
setRevokeTarget({ id: uplift.id, status: uplift.status, amount: uplift.amount })
}
/>
))}
{showEmpty && (
<Text as="p" variant="description" tone="muted" className="py-6 text-center">
No uplifts on this work order.
</Text>
)}
<CancelUpliftDialog
open={cancelTargetId != null}
pending={cancelUplift.isPending}
onClose={() => setCancelTargetId(null)}
onConfirm={() => {
if (cancelTargetId == null) return;
cancelUplift.mutate(cancelTargetId, { onSuccess: () => setCancelTargetId(null) });
}}
/>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title="Revoke this uplift?"
description={revokeDialogDescription(revokeTarget?.amount)}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) return;
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</div>
);
}

View file

@ -90,13 +90,13 @@ export function useSlideOverEditState({
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
// why: Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row.
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
// why: Reset draft/baseline only when the opened WO identity or editMode intent changes — not on every
// optimistic parent-row field update during a failed/in-flight save.
useSlideOverInitialization({
row,
editMode,
@ -109,9 +109,9 @@ export function useSlideOverEditState({
setUnsavedIntent,
});
// Re-apply the requested tab on every open, including a second notification for the work order
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so
// an in-flight edit draft is never discarded.
// why: Re-apply the requested tab on every open, including a second notification for the work order
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so an
// in-flight edit draft is never discarded.
useEffect(() => {
if (!row) return;
setTab(editMode ? "info" : initialTab);

View file

@ -131,7 +131,6 @@ export function useSlideOverMediaState({
[detailForRow?.media, ownership, completionHasGallery],
);
// Per-work-order photo/video counts span both tabs, so count the whole session.
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");

View file

@ -26,15 +26,15 @@ import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
export type SlideOverTab = "info" | "comments" | "completion" | "uplifts" | "extras" | "audit";
type UseWorkOrderSlideOverArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
@ -56,10 +56,10 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
// why: The upload transitions the completion document to Yes and bumps the work-order row version in
// the same server transaction; the mutation's onSuccess refreshes the board. A client docStatus
// patch here would carry the pre-upload version and 409 after a successful upload, so it is
// intentionally omitted.
mutate({ file, workOrderVersion: version });
}
@ -89,10 +89,10 @@ function slideOverErrorMessage(error: unknown) {
export function useWorkOrderSlideOver({
row,
editMode,
initialTab = "info",
onSave,
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
@ -111,7 +111,7 @@ export function useWorkOrderSlideOver({
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
const [tab, setTab] = useState<SlideOverTab>(initialTab);
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
const readFlags = slideOverReadFlags(infoSource?.status);
@ -133,7 +133,8 @@ export function useWorkOrderSlideOver({
categorizeMediaMutation,
});
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const [upliftCreatePending, setUpliftCreatePending] = useState(false);
const closeDisabled = Boolean(saving || mediaPending || completionPending || upliftCreatePending);
const editState = useSlideOverEditState({
row,
@ -146,7 +147,6 @@ export function useWorkOrderSlideOver({
initialTab,
openRequestKey,
});
const uploadCompletionPdf = (file: File) => {
uploadSlideOverCompletionPdf({
file,
@ -179,6 +179,8 @@ export function useWorkOrderSlideOver({
mediaPending,
completionPending,
closeDisabled,
upliftCreatePending,
setUpliftCreatePending,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,

View file

@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "uplifts", label: "Uplifts" },
{ id: "extras", label: "Extra Docs" },
{ id: "audit", label: "Audit Log" },
];
type WorkOrderSlideOverTabNavProps = {

View file

@ -4,6 +4,7 @@ import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/d
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab";
import type {
LocationOption,
VendorDropdownItem,
@ -17,6 +18,31 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
function SlideOverUpliftsPanel({
tab,
infoSource,
upliftCreatePending,
onUpliftCreatePendingChange,
}: {
tab: SlideOverTab;
infoSource: WorkOrderTableRow | null | undefined;
upliftCreatePending: boolean;
onUpliftCreatePendingChange?: (pending: boolean) => void;
}) {
const visible = tab === "uplifts";
if (infoSource == null || (!visible && !upliftCreatePending)) return null;
return (
<div hidden={!visible}>
<SlideOverUpliftsTab
workOrderId={infoSource.id}
workOrderType={infoSource.type}
status={infoSource.status}
onCreatePendingChange={onUpliftCreatePendingChange}
/>
</div>
);
}
type WorkOrderSlideOverTabsProps = {
tab: SlideOverTab;
state: SlideOverState;
@ -141,6 +167,13 @@ export function WorkOrderSlideOverTabs({
</WorkOrderSlideOverTabPanel>
)}
<SlideOverUpliftsPanel
tab={tab}
infoSource={infoSource}
upliftCreatePending={Boolean(state.upliftCreatePending)}
onUpliftCreatePendingChange={state.setUpliftCreatePending}
/>
{tab === "extras" && (
<WorkOrderSlideOverTabPanel
variant="extras"

View file

@ -24,6 +24,7 @@ export type SlideOverSaveOptions = {
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
@ -36,7 +37,6 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
commentHighlight?: CommentHighlight | null;
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
@ -46,6 +46,7 @@ type WorkOrderSlideOverProps = {
export function WorkOrderSlideOver({
row,
editMode,
initialTab = "info",
users,
sites,
vendors,
@ -58,7 +59,6 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
commentHighlight,
openRequestKey,
}: WorkOrderSlideOverProps) {

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

@ -1,48 +1,117 @@
import type { ReactNode } from "react";
import { Plus } from "lucide-react";
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
getUpliftCellVisual,
type UpliftCellVisual,
} from "@/domain/work-orders/utils/uplift-display-utils";
type UpliftCellProps = {
summary: UpliftSummary;
closed?: boolean;
onOpen?: () => void;
};
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
const CELL_DOT: Record<"pending" | "approved" | "rejected", string> = {
pending: "var(--warning)",
approved: "var(--success)",
rejected: "var(--destructive)",
};
if (!onOpen) {
return label ? (
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
);
}
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
const CELL_STATUS_COLOR: Record<"pending" | "approved" | "rejected", string> = {
pending: "var(--color-warning)",
approved: "var(--color-success)",
rejected: "var(--color-destructive)",
};
function UpliftCellButton({
label,
title,
onOpen,
children,
}: {
label: string;
title: string;
onOpen: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
aria-label={label}
title={title}
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
title={label ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
className="flex w-full items-center justify-start gap-1.5 text-left text-[11px] font-semibold whitespace-nowrap"
style={{ color: "var(--color-text-primary)" }}
>
{label && pillStyle ? (
<span
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={pillStyle}
>
{label}
</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}
{children}
</button>
);
}
function UpliftCellVisualBody({ visual }: { visual: UpliftCellVisual }) {
if (visual.kind === "plain") {
return <span>{visual.label}</span>;
}
const struck = visual.kind === "rejected";
return (
<>
<span
aria-hidden
className="inline-block shrink-0 rounded-full"
style={{ width: 7, height: 7, background: CELL_DOT[visual.kind] }}
/>
{visual.amount != null && (
<span
style={{
fontSize: 13,
fontWeight: struck ? 400 : 500,
color: struck ? "var(--color-text-muted)" : "var(--color-text-primary)",
textDecoration: struck ? "line-through" : "none",
}}
>
{visual.amount}
</span>
)}
<span style={{ fontSize: 11, color: CELL_STATUS_COLOR[visual.kind] }}>
{visual.statusLabel}
</span>
</>
);
}
export function UpliftCell({ summary, closed = false, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
const visual = getUpliftCellVisual(summary);
if (!onOpen) {
return <span className="text-[11px] text-[var(--color-text-muted)]">{label ?? "—"}</span>;
}
if (!label && closed) {
return (
<UpliftCellButton label="View uplifts" title="No uplift" onOpen={onOpen}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>—</span>
</UpliftCellButton>
);
}
if (!label) {
return (
<UpliftCellButton label="Add uplift" title="No uplift" onOpen={onOpen}>
<Plus size={13} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>Add</span>
</UpliftCellButton>
);
}
return (
<UpliftCellButton label={label} title="View uplifts" onOpen={onOpen}>
{visual != null && <UpliftCellVisualBody visual={visual} />}
</UpliftCellButton>
);
}

View file

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

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
}
closed={isWorkOrderUpliftsReadOnly(row.status)}
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
/>
</td>
</>

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