Compare commits

...

43 commits

Author SHA1 Message Date
Alexandre Brandizzi
414e584b36 Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog
# Conflicts:
#	src/domain/locations/api/locations-api.ts
#	src/domain/locations/types/location.ts
2026-09-25 17:34:41 -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
62664894bd Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog 2026-09-25 17:01:27 -03:00
Alexandre Brandizzi
35714a917d fix(workorders): Site dialog re-syncs a site after switching back and never syncs cached data
Switching to another site and back now loads the site record again, so its
extra contacts are not dropped on Save. The dialog also waits for the site
request to settle before syncing, so a record cached before an earlier save
is never shown or written back. Half-filled extra contacts stay off the site
record, as they already stay off the work order. The save path moves into
its own hook to keep the dialog state under the complexity limit.
2026-09-25 17:01:27 -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
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
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
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
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
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
136 changed files with 5726 additions and 507 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

@ -33,6 +33,9 @@ export const API_PATHS = {
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`,
completionTemplateLinkedWorkOrders: (id: string | number) =>
`workorders/completion-templates/${id}/linked-work-orders`,
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,

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,133 @@
import { FileText } from "lucide-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";
export interface CompletionTemplatePreviewValues {
name: string;
extraSafetyNote: string | null;
procedures: CompletionTemplateProcedure[];
}
type CompletionTemplatePreviewProps = {
values: CompletionTemplatePreviewValues;
isEditing: boolean;
};
function PreviewPill({ isEditing }: { isEditing: boolean }) {
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"}
</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>
</>
);
}
function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
if (procedures.length === 0) {
return (
<Text
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 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} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
data-testid="completion-template-preview"
>
<CompDocDocumentFrame>
<CompDocPreviewHeader />
<PreviewTitle name={values.name} />
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
<PreviewProcedures procedures={values.procedures} />
</CompDocDocumentFrame>
</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,36 @@
import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
import { useAuthContext } from "@/providers/auth-context";
const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"];
export interface CompletionTemplatePermissions {
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
}
/**
* The signed-in user's session carries roles, not per-user permission overrides, so each key
* resolves to the role default the Team Members screen and the server policy start from.
*/
export function completionTemplatePermissions(
userRoles: string | null | undefined,
): CompletionTemplatePermissions {
const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)]));
return {
canCreate: granted.has("createCompletionDocTemplates"),
canEdit: granted.has("editCompletionDocTemplates"),
canDelete: granted.has("deleteCompletionDocTemplates"),
};
}
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const { user } = useAuthContext();
return completionTemplatePermissions(user?.userRoles);
}

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

@ -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 this site?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently remove {target?.name} from Sites. This 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,8 +1,6 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
@ -10,7 +8,6 @@ import {
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onOpen: (row: LocationListItem) => void;
rows: LocationListItem[];
selectedId?: string | null;
}
const COLUMN_COUNT = 8;
const COLUMN_COUNT = 7;
export function LocationsTable({
isLoading,
onDelete,
onOpen,
rows,
selectedId = null,
@ -46,9 +41,6 @@ export function LocationsTable({
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">POC Name</TableCell>
<TableCell className="font-semibold">POC Phone</TableCell>
<TableCell align="right" className="font-semibold">
<span className="sr-only">Actions</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -98,20 +90,6 @@ export function LocationsTable({
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Delete">
<IconButton
size="small"
aria-label={`Delete site ${row.name}`}
onClick={(event) => {
event.stopPropagation();
onDelete(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>

View file

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

View file

@ -1,8 +1,11 @@
import { useEffect, useState } from "react";
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";
@ -36,6 +39,8 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () =
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 (
@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
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}
/>
</>
)}
</>

View file

@ -1,5 +1,7 @@
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
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";
@ -26,6 +28,9 @@ export function SiteFormFields({
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}>
@ -50,18 +55,27 @@ export function SiteFormFields({
)}
/>
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
<TextField
{...register("address")}
<AddressAutocompleteField
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");
}}
label="Street Address"
placeholder="Street address"
required
requiredMarker
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
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
{...register("city")}
{...cityField}
label="City"
required
error={Boolean(errors.city)}

View file

@ -9,14 +9,11 @@ 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";
@ -25,12 +22,10 @@ export default function LocationsListPage() {
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]);
@ -50,20 +45,6 @@ export default function LocationsListPage() {
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
const deletedId = String(deleteTarget.id);
deleteLocation.mutate(deleteTarget.id, {
onSuccess: () => {
setDeleteTarget(null);
setSelectedId((current) => (current === deletedId ? null : current));
},
});
};
return (
<Box
className="flex flex-col gap-4 p-4"
@ -120,7 +101,6 @@ export default function LocationsListPage() {
rows={rows}
selectedId={selectedId}
onOpen={(row) => setSelectedId(String(row.id))}
onDelete={setDeleteTarget}
/>
<TablePagination
component="div"
@ -136,12 +116,6 @@ export default function LocationsListPage() {
/>
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
</Box>
);
}

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,55 @@
import type { ReactNode } from "react";
type CompDocDocumentFrameProps = {
children: ReactNode;
/** Greyed-out page, as for a document marked not required. */
dimmed?: boolean;
/** Rendered over the page, outside the dimmed layer. */
overlay?: ReactNode;
};
/** The printed page: white sheet between the top and bottom brand bands. */
export function CompDocDocumentFrame({
children,
dimmed = false,
overlay,
}: CompDocDocumentFrameProps) {
return (
<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: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>{children}</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{overlay}
</div>
);
}

View file

@ -1,7 +1,9 @@
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";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
@ -13,34 +15,9 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
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>
<CompDocSafetyBullets />
<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={{
@ -56,18 +33,7 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{

View file

@ -1,5 +1,6 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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";
@ -38,51 +39,19 @@ export function CompDocPreview({
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",
}}
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<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>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</CompDocDocumentFrame>
</div>
</div>
);

View file

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

View file

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

View file

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

@ -0,0 +1,82 @@
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
import {
workOrderPocAfterSiteSave,
type useSiteRecordSync,
type WorkOrderPoc,
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
type UseSiteDialogSaveArgs = {
fields: ReturnType<typeof useSiteDialogFormFields>;
siteRecord: ReturnType<typeof useSiteRecordSync>;
/** The work order's values when the dialog opened. */
original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] };
createMode: boolean;
/** The first contact is required before Save (inline create, or editing the site record). */
requiresPoc: boolean;
onSave: (patch: SitePatch) => void;
onOpenChange: (open: boolean) => void;
};
/**
* The Site dialog's Save: writes changed contacts and notes to the site record first when the
* dialog edits it, then patches the work order and closes.
*/
export function useSiteDialogSave({
fields,
siteRecord,
original,
createMode,
requiresPoc,
onSave,
onOpenChange,
}: UseSiteDialogSaveArgs) {
const updateSite = useUpdateSiteContactInfo();
const pocMissing = !fields.pn.trim() || !fields.pp.trim();
const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing);
const saveWorkOrder = () => {
const poc = workOrderPocAfterSiteSave(
siteRecord,
original,
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
fields.locId !== resolveLocationId(original.locationId, original.value, original.sites),
);
onSave(
buildSiteDialogPatch({
code: fields.code,
locId: fields.locId,
selected: fields.selected,
...poc,
extraContacts: fields.extraContacts,
contactsDirty: fields.contactsDirty,
baselineHadContacts: fields.baselineHadContacts,
followsSiteRecord: siteRecord.synced,
}),
);
onOpenChange(false);
};
const attemptSave = () => {
if (requiresPoc && !canConfirm) {
fields.setShowErrors(true);
return;
}
if (!siteRecord.siteChanged) {
saveWorkOrder();
return;
}
updateSite.mutate({ id: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
};
return {
attemptSave,
// Save is actionable only once something changed; inline create keeps its confirm step.
saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty),
saving: updateSite.isPending,
};
}

View file

@ -1,15 +1,11 @@
import { useEffect, useMemo } from "react";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import {
useSiteRecordSync,
workOrderPocAfterSiteSave,
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
import { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save";
import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
@ -32,6 +28,20 @@ type UseSiteDialogStateArgs = {
onSave: (patch: SitePatch) => void;
};
/** A completed work order's frozen snapshot, shaped like the live site detail. */
function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location {
return {
name: frozenSite.label,
address: frozenSite.address,
city: frozenSite.city,
state: frozenSite.state,
zipCode: frozenSite.zip,
phone: frozenSite.phone,
contact: undefined,
contactEmail: frozenSite.email,
};
}
export function useSiteDialogState({
open,
onOpenChange,
@ -58,96 +68,51 @@ export function useSiteDialogState({
sites,
createMode,
});
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
const {
data: liveLocationDetail,
isLoading: liveLocationDetailLoading,
isError: liveLocationDetailError,
} = useLocationDetail(frozenSite == null && open && locId ? locId : undefined);
const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields;
const live = frozenSite == null;
const liveDetail = useLocationDetail(live && open && locId ? locId : undefined);
const frozenLocationDetail = useMemo(
() =>
frozenSite == null
? undefined
: {
name: frozenSite.label,
address: frozenSite.address,
city: frozenSite.city,
state: frozenSite.state,
zipCode: frozenSite.zip,
phone: frozenSite.phone,
contact: undefined,
contactEmail: frozenSite.email,
},
() => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)),
[frozenSite],
);
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
const locationDetailError = frozenSite == null && liveLocationDetailError;
const locationDetail = frozenLocationDetail ?? liveDetail.data;
const locationDetailLoading = live && liveDetail.isLoading;
const locationDetailError = live && liveDetail.isError;
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
// An existing, editable work order edits the site record itself (contacts and notes).
const editsSiteRecord = !createMode && !viewOnly && frozenSite == null;
const editsSiteRecord = !createMode && !viewOnly && live;
const siteRecord = useSiteRecordSync({
enabled: editsSiteRecord,
open,
locId,
locationDetail: liveLocationDetail,
locationDetailError: liveLocationDetailError,
locationDetail: liveDetail.data,
locationDetailFetching: liveDetail.isFetching,
locationDetailError: liveDetail.isError,
fields,
});
// The site failed to load (or the user typed before it did), so Save writes this work order only.
const siteRecordUnavailable =
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
const updateSite = useUpdateSiteContactInfo();
const pocMissing = !pn.trim() || !pp.trim();
const requiresPoc = createMode || editsSiteRecord;
const canConfirm = !siteMissing && (!requiresPoc || !pocMissing);
const save = useSiteDialogSave({
fields,
siteRecord,
original: { pocName, pocPhone, pocNotes, locationId, value, sites },
createMode,
requiresPoc,
onSave,
onOpenChange,
});
useEffect(() => {
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
const saveWorkOrder = () => {
const poc = workOrderPocAfterSiteSave(
siteRecord,
{ pocName, pocPhone, pocNotes },
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
fields.locId !== resolveLocationId(locationId, value, sites),
);
onSave(
buildSiteDialogPatch({
code: fields.code,
locId: fields.locId,
selected: fields.selected,
...poc,
extraContacts: fields.extraContacts,
contactsDirty: fields.contactsDirty,
baselineHadContacts: fields.baselineHadContacts,
followsSiteRecord: siteRecord.synced,
}),
);
onOpenChange(false);
};
const attemptSave = () => {
if (requiresPoc && !canConfirm) {
fields.setShowErrors(true);
return;
}
if (!siteRecord.siteChanged) {
saveWorkOrder();
return;
}
updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
};
// Save is actionable only once something changed; inline create keeps its confirm step.
const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty);
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
return {
locId: fields.locId,
@ -164,9 +129,9 @@ export function useSiteDialogState({
showErrors: fields.showErrors,
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
attemptSave,
saveDisabled,
saving: updateSite.isPending,
attemptSave: save.attemptSave,
saveDisabled: save.saveDisabled,
saving: save.saving,
siteRecordLoading: siteRecord.loading,
editsSiteRecord,
siteRecordUnavailable,

View file

@ -22,6 +22,8 @@ type UseSiteRecordSyncArgs = {
open: boolean;
locId: string;
locationDetail: Location | undefined;
/** A request for the site is in flight; cached data may predate the latest save. */
locationDetailFetching: boolean;
locationDetailError: boolean;
fields: SiteRecordFields;
};
@ -30,6 +32,10 @@ export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string
type SiteRecordEdits = { primary: boolean; notes: boolean };
function isCompleteContact(contact: WorkOrderAdditionalContact): boolean {
return contact.name.trim() !== "" && contact.phone.trim() !== "";
}
function toRequest(
primaryId: number | undefined,
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
@ -37,7 +43,8 @@ function toRequest(
return {
contacts: [
{ ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
...fields.extraContacts.map((contact) => ({
// A half-filled extra is dropped here, the same as the work order's own copy drops it.
...fields.extraContacts.filter(isCompleteContact).map((contact) => ({
...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
name: contact.name,
phone: contact.phone,
@ -92,6 +99,22 @@ export function workOrderPocAfterSiteSave(
};
}
/** The site record's contacts and notes, in the dialog's field shape. */
function siteRecordValues(location: Location) {
const [main, ...others] = location.contacts ?? [];
return {
primaryId: main?.id,
pn: main?.name ?? "",
pp: main?.phone ?? "",
notes: location.notes ?? "",
extraContacts: others.map((contact) => ({
name: contact.name,
phone: contact.phone,
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
})),
};
}
/**
* Loads the selected site's contacts and notes into the Site dialog and reports what the user
* changed, so Save can write them back to the site record.
@ -101,6 +124,7 @@ export function useSiteRecordSync({
open,
locId,
locationDetail,
locationDetailFetching,
locationDetailError,
fields,
}: UseSiteRecordSyncArgs) {
@ -109,43 +133,34 @@ export function useSiteRecordSync({
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
// Picking another site clears its contacts, so coming back to a site must load it again.
useEffect(() => {
setSyncedFor("");
}, [open, resetVersion]);
}, [open, resetVersion, locId]);
useEffect(() => {
const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
// Cached site data can predate a save made since, so only a settled request is synced.
const detailCurrent = detailMatches && !locationDetailFetching;
// Input typed while the site was unavailable (a failed load) is never replaced by a late
// response: the dialog stays on the work order's values and saves them to the work order only.
if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) {
if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) {
return;
}
const [main, ...others] = locationDetail.contacts ?? [];
const extras = others.map((contact) => ({
name: contact.name,
phone: contact.phone,
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
}));
const notes = locationDetail.notes ?? "";
setPn(main?.name ?? "");
setPp(main?.phone ?? "");
setExtraContacts(extras);
setNotes(notes);
setPrimaryId(main?.id);
setBaseline(
toRequest(main?.id, {
pn: main?.name ?? "",
pp: main?.phone ?? "",
notes,
extraContacts: extras,
}),
);
const site = siteRecordValues(locationDetail);
setPn(site.pn);
setPp(site.pp);
setExtraContacts(site.extraContacts);
setNotes(site.notes);
setPrimaryId(site.primaryId);
setBaseline(toRequest(site.primaryId, site));
setSyncedFor(locId);
}, [
enabled,
open,
locId,
locationDetail,
locationDetailFetching,
syncedFor,
userEdited,
setPn,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
import { z } from "zod";
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: passwordSchema,
confirmPassword: z.string(),
})
.refine((values) => values.newPassword === values.confirmPassword, {
path: ["confirmPassword"],
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
});
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;

View file

@ -3,14 +3,13 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
/** Errors are rendered inline by the form that initiated the change. */
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
return useMutation({
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
meta: { suppressErrorToast: true },
onSuccess: () => {
toast.success("Password changed successfully!");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to change password");
toast.success("Password updated");
},
});
}

View file

@ -0,0 +1,64 @@
import type { z } from "zod";
import { apiDeleteNoContent, apiGet, apiPost, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapCompletionTemplate } from "@/domain/completion-templates/mappers/completion-template-mapper";
import {
completionTemplateDtoSchema,
completionTemplateListDtoSchema,
linkedWorkOrdersDtoSchema,
} from "@/domain/completion-templates/schemas/completion-template-schema";
import type {
CompletionTemplate,
CompletionTemplateInput,
CompletionTemplateLegacyFields,
LinkedWorkOrders,
} from "@/domain/completion-templates/types/completion-template";
export const UNEXPECTED_TEMPLATE_RESPONSE = "The server sent an unexpected response.";
function parse<T>(schema: z.ZodType<T>, data: unknown): T {
const result = schema.safeParse(handleApiResponse<unknown>(data));
if (!result.success) throw new Error(UNEXPECTED_TEMPLATE_RESPONSE);
return result.data;
}
function toBody(input: CompletionTemplateInput, legacy?: CompletionTemplateLegacyFields) {
return { ...legacy, ...input };
}
export const completionTemplatesApi = {
list: async (search: string): Promise<CompletionTemplate[]> => {
const term = search.trim();
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates, {
searchParams: term === "" ? undefined : { search: term },
});
return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate);
},
create: async (input: CompletionTemplateInput): Promise<CompletionTemplate> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.completionTemplates, toBody(input));
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
},
/** Replaces the whole procedure list and keeps the legacy fields the template already had. */
update: async (
template: CompletionTemplate,
input: CompletionTemplateInput,
): Promise<CompletionTemplate> => {
const data = await apiPut<unknown>(
API_PATHS.workOrder.completionTemplateById(template.id),
toBody(input, template.legacy),
);
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
},
delete: async (id: number): Promise<void> => {
await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id));
},
linkedWorkOrders: async (id: number): Promise<LinkedWorkOrders> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id));
return parse(linkedWorkOrdersDtoSchema, data);
},
};

View file

@ -0,0 +1,27 @@
/** Filled in on every generated document; shown read-only so authors don't retype them. */
export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const;
/** Server limits; the inputs stop there instead of failing the save. */
export const PROCEDURE_NAME_MAX_LENGTH = 200;
export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000;
export const COMPLETION_TEMPLATE_COPY = {
pageTitle: "Completion Docs",
searchPlaceholder: "Search document title…",
addTemplate: "Add Template",
saveChanges: "Save changes",
titleRequired: "Document Title is required",
noProcedureDetail: "No procedure defined yet",
noProcedurePreview: "No procedure added yet.",
untitledDocument: "UNTITLED COMPLETION DOCUMENT",
autoFilledSuffix: "— auto-filled per work order",
emptySearchTitle: "No templates found",
emptySearchHint: "Try a different search, or add a new template.",
added: "Template added",
updated: "Template updated",
deleted: "Template deleted",
} as const;
export function templateCountLabel(count: number): string {
return `${count} template${count === 1 ? "" : "s"}`;
}

View file

@ -0,0 +1,63 @@
import type {
CompletionTemplateDto,
CompletionTemplateFormValues,
} from "@/domain/completion-templates/schemas/completion-template-schema";
import type {
CompletionTemplate,
CompletionTemplateInput,
} from "@/domain/completion-templates/types/completion-template";
export function mapCompletionTemplate(dto: CompletionTemplateDto): CompletionTemplate {
const createdAt = dto.createdAt ?? "";
const procedures = [...(dto.procedures ?? [])]
.map((procedure, index) => ({ procedure, order: procedure.sortOrder ?? index }))
.sort((a, b) => a.order - b.order)
.map(({ procedure }) => ({
name: procedure.name ?? "",
description: procedure.description ?? "",
}));
return {
id: dto.id,
name: dto.name ?? "",
extraSafetyNote: dto.extraSafetyNote?.trim() ? dto.extraSafetyNote : null,
procedures,
createdAt,
lastUpdatedAt: dto.updatedAt ?? createdAt,
createdByName: dto.createdByName?.trim() ? dto.createdByName : null,
legacy: {
serviceKey: dto.serviceKey ?? "",
workOrderType: dto.workOrderType ?? null,
templateUrl: dto.templateUrl ?? "",
isActive: dto.isActive ?? true,
},
};
}
export function toCompletionTemplateFormValues(
template: CompletionTemplate | null,
): CompletionTemplateFormValues {
if (!template) return { name: "", extraSafetyNote: null, procedures: [] };
return {
name: template.name,
extraSafetyNote: template.extraSafetyNote,
procedures: template.procedures.map((procedure) => ({ ...procedure })),
};
}
/** Trims every field and drops procedures the author left entirely blank. */
export function toCompletionTemplateInput(
values: CompletionTemplateFormValues,
): CompletionTemplateInput {
const note = values.extraSafetyNote?.trim() ?? "";
return {
name: values.name.trim(),
extraSafetyNote: note === "" ? null : note,
procedures: values.procedures
.map((procedure) => ({
name: procedure.name.trim(),
description: procedure.description.trim(),
}))
.filter((procedure) => procedure.name !== "" || procedure.description !== ""),
};
}

View file

@ -0,0 +1,45 @@
import { z } from "zod";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
const procedureDtoSchema = z.object({
name: z.string().nullish(),
description: z.string().nullish(),
sortOrder: z.number().nullish(),
});
export const completionTemplateDtoSchema = z.object({
id: z.number(),
name: z.string().nullish(),
extraSafetyNote: z.string().nullish(),
procedures: z.array(procedureDtoSchema).nullish(),
createdAt: z.string().nullish(),
updatedAt: z.string().nullish(),
createdByName: z.string().nullish(),
serviceKey: z.string().nullish(),
workOrderType: z.number().nullish(),
templateUrl: z.string().nullish(),
isActive: z.boolean().nullish(),
});
export const completionTemplateListDtoSchema = z.array(completionTemplateDtoSchema);
export const linkedWorkOrdersDtoSchema = z.object({
count: z.number(),
workOrderIds: z.array(z.number()),
});
export type CompletionTemplateDto = z.infer<typeof completionTemplateDtoSchema>;
const procedureFormSchema = z.object({
name: z.string(),
description: z.string(),
});
export const completionTemplateFormSchema = z.object({
name: z.string().trim().min(1, COMPLETION_TEMPLATE_COPY.titleRequired),
/** `null` until the author adds the optional note. */
extraSafetyNote: z.string().nullable(),
procedures: z.array(procedureFormSchema),
});
export type CompletionTemplateFormValues = z.infer<typeof completionTemplateFormSchema>;

View file

@ -0,0 +1,38 @@
export interface CompletionTemplateProcedure {
name: string;
description: string;
}
/** Fields the Services registry still reads; an edit sends them back unchanged. */
export interface CompletionTemplateLegacyFields {
serviceKey: string;
workOrderType: number | null;
templateUrl: string;
isActive: boolean;
}
export interface CompletionTemplate {
id: number;
/** The Document Title. */
name: string;
extraSafetyNote: string | null;
/** In print order. */
procedures: CompletionTemplateProcedure[];
createdAt: string;
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */
lastUpdatedAt: string;
createdByName: string | null;
legacy: CompletionTemplateLegacyFields;
}
export interface CompletionTemplateInput {
name: string;
extraSafetyNote: string | null;
procedures: CompletionTemplateProcedure[];
}
/** Open work orders whose completion document would be generated from a template. */
export interface LinkedWorkOrders {
count: number;
workOrderIds: number[];
}

View file

@ -0,0 +1,85 @@
import {
useMutation,
useQueryClient,
type QueryClient,
type UseMutationResult,
} from "@tanstack/react-query";
import { isHTTPError } from "ky";
import { toast } from "react-toastify";
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type {
CompletionTemplate,
CompletionTemplateInput,
} from "@/domain/completion-templates/types/completion-template";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateCompletionTemplateVariables {
template: CompletionTemplate;
input: CompletionTemplateInput;
}
/** The server's blank-title rejection, which the form shows on the title field instead. */
export function isTitleRequiredError(error: Error): boolean {
if (!isHTTPError(error) || error.response.status !== 422) return false;
const body: unknown = error.data;
return (
typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired"
);
}
function invalidateTemplates(queryClient: QueryClient) {
void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all });
// The Services registry lists template names and clears links to a deleted template.
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
}
function toastFailure(error: Error, fallback: string) {
if (isTitleRequiredError(error)) return;
toast.error(error.message || fallback);
}
export function useCreateCompletionTemplate(): UseMutationResult<
CompletionTemplate,
Error,
CompletionTemplateInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CompletionTemplateInput) => completionTemplatesApi.create(input),
onSuccess: () => {
invalidateTemplates(queryClient);
toast.success(COMPLETION_TEMPLATE_COPY.added);
},
onError: (error: Error) => toastFailure(error, "Failed to add template"),
});
}
export function useUpdateCompletionTemplate(): UseMutationResult<
CompletionTemplate,
Error,
UpdateCompletionTemplateVariables
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ template, input }: UpdateCompletionTemplateVariables) =>
completionTemplatesApi.update(template, input),
onSuccess: () => {
invalidateTemplates(queryClient);
toast.success(COMPLETION_TEMPLATE_COPY.updated);
},
onError: (error: Error) => toastFailure(error, "Failed to update template"),
});
}
export function useDeleteCompletionTemplate(): UseMutationResult<void, Error, number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => completionTemplatesApi.delete(id),
onSuccess: () => {
invalidateTemplates(queryClient);
toast.success(COMPLETION_TEMPLATE_COPY.deleted);
},
onError: (error: Error) => toastFailure(error, "Failed to delete template"),
});
}

View file

@ -0,0 +1,30 @@
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
import type {
CompletionTemplate,
LinkedWorkOrders,
} from "@/domain/completion-templates/types/completion-template";
import { queryKeys } from "@/infra/query-key/query-key";
/** `search` is a partial, case-insensitive Document Title match done by the server. */
export function useCompletionTemplates(search = ""): UseQueryResult<CompletionTemplate[], Error> {
const term = search.trim();
return useQuery({
queryKey: queryKeys.completionTemplates.list(term),
queryFn: () => completionTemplatesApi.list(term),
placeholderData: keepPreviousData,
});
}
/** Always refetched when asked for: the count gates a destructive action. */
export function useLinkedWorkOrders(
templateId: number | null,
): UseQueryResult<LinkedWorkOrders, Error> {
return useQuery({
queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0),
queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0),
enabled: templateId !== null,
staleTime: 0,
refetchOnMount: "always",
});
}

View file

@ -0,0 +1,7 @@
/** A procedure description prints one paragraph per non-blank line. */
export function descriptionLines(description: string): string[] {
return description
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

View file

@ -0,0 +1,6 @@
/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */
export function formatTemplateDate(value: string): string {
const date = new Date(value);
if (value === "" || Number.isNaN(date.getTime())) return "—";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}

View file

@ -7,11 +7,13 @@ import {
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
import type {
Location,
LocationSite,
SiteContactInfoInput,
SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@ -84,4 +86,9 @@ export const locationsApi = {
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
},
getOpenWorkOrders: async (id: string | number): Promise<SiteOpenWorkOrders> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.locations}/${id}/open-work-orders`);
return mapSiteOpenWorkOrders(handleApiResponse(data));
},
};

View file

@ -3,6 +3,7 @@ import type {
LocationContact,
LocationListItem,
LocationSite,
SiteOpenWorkOrders,
} from "@/domain/locations/types/location";
function readString(record: Record<string, unknown>, ...keys: string[]): string {
@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
return payload;
}
export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders {
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const rawIds = item.workOrderIds ?? item.WorkOrderIds;
const workOrderIds = Array.isArray(rawIds)
? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0)
: [];
const count = item.count ?? item.Count;
return {
count: typeof count === "number" && count >= 0 ? count : workOrderIds.length,
workOrderIds,
};
}

View file

@ -75,3 +75,9 @@ export interface SiteContactInfoInput {
contacts: LocationContact[];
notes: string;
}
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
export interface SiteOpenWorkOrders {
count: number;
workOrderIds: number[];
}

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { SiteOpenWorkOrders } from "@/domain/locations/types/location";
import { queryKeys } from "@/infra/query-key/query-key";
/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */
export function useSiteOpenWorkOrders(
siteId: string,
enabled: boolean,
): UseQueryResult<SiteOpenWorkOrders, Error> {
return useQuery({
queryKey: queryKeys.locations.openWorkOrders(siteId),
queryFn: () => locationsApi.getOpenWorkOrders(siteId),
enabled,
staleTime: 0,
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
// client never confirmed, so it lists the PM catalog.
const TYPE_TO_API: Record<WOType, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
Overdue: WORK_ORDER_TYPE_API.PM,
};
function toApiInput(input: ServiceInput) {
@ -51,7 +54,6 @@ export const servicesApi = {
},
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
if (workOrderType === "Overdue") return [];
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
});

View file

@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
return useQuery({
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
enabled: effectiveType !== "Overdue",
staleTime: 60 * 1000,
});
}

View file

@ -10,5 +10,8 @@ export function useUpliftsQueue(
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
queryFn: () => upliftsApi.list(params),
placeholderData: keepPreviousData,
// The Uplift Approvals page reports a failed queue inline, so the global toast
// would announce the same failure twice.
meta: { suppressErrorToast: true },
});
}

View file

@ -1,4 +1,5 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
payload.vendorNotes = vendorNotes;
}
if (draft.severity != null) {
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
payload.severity = draft.severity;
}

View file

@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
Reactive: WORK_ORDER_TYPE_API.Reactive,
Overdue: WORK_ORDER_TYPE_API.Other,
Overdue: WORK_ORDER_TYPE_API.Overdue,
};
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */

View file

@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
dispatchers: Set<string>;
regions: Set<string>;
statuses: Set<WizardWOStatus>;
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
pastDue: boolean;
rescheduled: boolean;
carriedOver: boolean;
addOn: boolean;
@ -46,6 +48,8 @@ export interface WorkOrderAdvancedFilters {
internalOnly: boolean;
hasUplift: boolean;
upliftStatuses: Set<UpliftFilterStatus>;
/** Exact work orders to show; when non-empty the server ignores every other filter. */
workOrderIds: Set<number>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
@ -59,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dispatchers: new Set(),
regions: new Set(),
statuses: new Set(),
pastDue: false,
rescheduled: false,
carriedOver: false,
addOn: false,
@ -70,6 +75,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
internalOnly: false,
hasUplift: false,
upliftStatuses: new Set(),
workOrderIds: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [

View file

@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
Inspection: 5,
Reactive: 6,
AddOn: 7,
Overdue: 8,
Other: 99,
} as const;
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM",
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
};
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
@ -326,6 +328,8 @@ export interface WorkOrderBoardSearchQueryParams {
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
ids?: string;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string;
}

View file

@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
clear: (current) => ({ ...current, statuses: new Set() }),
});
if (filters.pastDue) {
chips.push({
key: "pastdue",
group: "Status",
value: "Past Due",
clear: (current) => ({ ...current, pastDue: false }),
});
}
pushSetChip({
chips,
key: "pm",
@ -306,3 +315,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
clear: clearUplift,
});
}
export function appendWorkOrderIdsChip(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
const count = filters.workOrderIds?.size ?? 0;
if (count === 0) return;
chips.push({
key: "workOrderIds",
group: "Work orders",
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
});
}

View file

@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
!filters.sites.size || filters.sites.has(row.site);
const matchesType: AdvancedFilterPredicate = (row, filters) => {
if (!filters.types.size) return true;
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
const typeMatch = filters.types.has(row.type as never);
return overdueMatch || typeMatch;
};
const matchesType: AdvancedFilterPredicate = (row, filters) =>
!filters.types.size || filters.types.has(row.type as never);
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
if (!filters.dispatchers.size) return true;
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesType,
matchesDispatcher,
matchesStatus,
matchesPastDue,
matchesPmType,
matchesVendorTech,
matchesDoc,

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