Compare commits

...

32 commits

Author SHA1 Message Date
Alexandre Brandizzi
f068beb45e
Merge pull request #277 from Sea-Haven-Industries/fix/ab/sh-402-effective-permissions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(completion-docs): show template actions from the user's effective permissions
2026-09-25 20:31:22 +00:00
Alexandre Brandizzi
06f70c193d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
#	src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
2026-09-25 17:26:10 -03:00
Alexandre Brandizzi
e8cdb9814a
Merge pull request #273 from Sea-Haven-Industries/feat/ab/sh-334-site-delete
feat(sites): delete a site from the detail panel with an open work-order warning
2026-09-25 20:22:20 +00:00
Alexandre Brandizzi
8e02031152
Merge pull request #268 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-page
feat(completion-docs): manage completion document templates
2026-09-25 20:18:17 +00:00
Alexandre Brandizzi
7bde8aacf2 fix(sites): hold Delete until the reopened dialog's open work-order recheck lands
The open work-order query keeps its cached result between dialog opens, so on
reopen the background refetch ran while isLoading was false and Delete was
enabled with the previous count. A work order created at the site since the
last open could be deleted past without a warning.

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

The exact-id link is now used only when the ids cover the whole count.
Otherwise the link opens Work Orders filtered to the site and every open
status across all weeks, the ticket's "board filtered to that site". The
board drilldown now reads a `sites` param for this; `ids` still wins.
2026-09-25 17:10:51 -03:00
Alexandre Brandizzi
85a45e9fc0
Merge pull request #279 from Sea-Haven-Industries/feat/ab/sh-389-rejected-uplift-queue
feat(uplifts): add the Rejected archive tab to Uplift Approvals (SH-389)
2026-09-25 20:08:16 +00:00
Alexandre Brandizzi
090c977659 fix(uplifts): report a failed approvals queue once, inline, without the global toast 2026-09-25 17:03:15 -03:00
Alexandre Brandizzi
d866cf1b87 fix(completion-docs): hide the empty-search row when templates fail to load 2026-09-25 17:00:23 -03:00
Alexandre Brandizzi
73f639c271
Merge pull request #269 from Sea-Haven-Industries/feat/ab/sh-322-overdue-type
feat(work-orders): send Overdue as its own work order type
2026-09-25 19:57:00 +00:00
Alexandre Brandizzi
a031239259 fix(uplifts): keep the count and pager when a refetch fails over loaded rows 2026-09-25 16:56:35 -03:00
Alexandre Brandizzi
7c38ebd579 Merge commit 'd281cbd663b7b4106f104cb40207e4d5d1271387' into HEAD 2026-09-25 16:51:25 -03:00
Alexandre Brandizzi
b53d91b1da Merge remote-tracking branch 'origin/main' into HEAD 2026-09-25 16:48:47 -03:00
Alexandre Brandizzi
3ed45547db fix(uplifts): keep a failed queue from rendering as an empty archive 2026-09-25 16:48:46 -03:00
Alexandre Brandizzi
d281cbd663 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-25 16:46:17 -03:00
Alexandre Brandizzi
70501693c8 fix(uplifts): show an unavailable count pill while a queue is loading or failed 2026-09-25 13:43:11 -03:00
Alexandre Brandizzi
70e52937aa feat(uplifts): add the rejected uplifts archive tab to Uplift Approvals 2026-09-25 13:23:32 -03:00
Alexandre Brandizzi
9c19287172 test(completion-docs): keep the dialog closed when a save finishes after closing 2026-09-25 12:53:43 -03:00
Alexandre Brandizzi
d5cd0659a2 fix(completion-docs): gate template actions on the user's effective permissions
Add Template, Edit and Delete now follow the permissions the server
computes for the signed-in user, role defaults plus their individual
overrides, instead of role defaults alone. The actions stay hidden while
the permissions load or when they fail to load.
2026-09-25 12:34:01 -03:00
Alexandre Brandizzi
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
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
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
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
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
101 changed files with 3969 additions and 382 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

@ -33,6 +33,9 @@ export const API_PATHS = {
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`,
completionTemplateLinkedWorkOrders: (id: string | number) =>
`workorders/completion-templates/${id}/linked-work-orders`,
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -83,6 +86,7 @@ export const API_PATHS = {
getList: "User",
create: "team-members",
detail: "team-members",
myPermissions: "team-members/me/permissions",
},
contact: {
getList: "Contact/GetContactList",

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

View file

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

View file

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

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

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

View file

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

View file

@ -16,14 +16,20 @@ import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
UPLIFT_APPROVAL_HEADER_CELLS,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import {
RejectedEmptyState,
RejectedRowCells,
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
@ -236,65 +242,84 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<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

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

View file

@ -0,0 +1,32 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions";
import { getMyPermissions } from "@/domain/auth/api/permissions-api";
import { queryKeys } from "@/infra/query-key/query-key";
import { useAuthContext } from "@/providers/auth-context";
/**
* The signed-in user's effective permissions as the server computes them. Keyed by user so a
* different sign-in never reads another person's answer, and refetched on every mount and window
* focus so an override change applies on the next page load.
*/
export function usePermissions(): UseQueryResult<string[], Error> {
const { user } = useAuthContext();
const userId = user?.id ?? null;
return useQuery({
queryKey: queryKeys.auth.permissions(userId ?? ""),
queryFn: getMyPermissions,
enabled: userId !== null,
staleTime: 0,
refetchOnWindowFocus: true,
meta: { suppressErrorToast: true },
});
}
/** Holds only for a successful server answer that lists the key; loading and errors deny. */
export function hasPermission(query: UseQueryResult<string[], Error>, key: PermissionKey): boolean {
return !query.isError && query.data?.includes(key) === true;
}
export function useHasPermission(key: PermissionKey): boolean {
return hasPermission(usePermissions(), key);
}

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

@ -6,10 +6,12 @@ import {
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
import type {
Location,
LocationSite,
SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@ -74,4 +76,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

@ -69,3 +69,9 @@ export interface LocationsListResult {
page: number;
pageSize: number;
}
/** 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

@ -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;
@ -61,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,

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"> = {

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",

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,

View file

@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
upliftStatuses: new Set(filters.upliftStatuses ?? []),
workOrderIds: new Set(filters.workOrderIds ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly),
pastDue: Boolean(filters.pastDue),
};
}
@ -47,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
Boolean(filters.dispatchers?.size),
Boolean(filters.regions?.size),
Boolean(filters.statuses?.size),
Boolean(filters.pastDue),
filters.rescheduled,
filters.carriedOver,
filters.addOn,

View file

@ -42,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
return list.length > 0 ? list : undefined;
}
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
function mapWoTypeFilters(types: Iterable<WOType | string>): {
types?: number[];
overdue?: boolean;
} {
/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
const list: number[] = [];
let overdue = false;
for (const type of types) {
if (type === "All") {
continue;
}
if (type === "Overdue") {
overdue = true;
continue;
}
const api = mapWizardTypeToApi(type as WOType);
if (api != null) {
list.push(api);
}
}
return {
types: list.length > 0 ? list : undefined,
overdue: overdue || undefined,
};
return list.length > 0 ? list : undefined;
}
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
@ -147,14 +136,11 @@ export function toBoardQueryParams(
search: string,
): WorkOrderBoardQueryParams {
const [, weekTo] = weekRange(bar.weekMonday);
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
return {
weekStart: bar.weekMonday,
weekEnd: weekTo,
dispatchers: mapDispatchers(bar.dispatcherIds),
types: typeFilters.types,
overdue: typeFilters.overdue,
types: mapWoTypeFilters(bar.types),
search: search.trim().length >= 2 ? search.trim() : undefined,
};
}
@ -211,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
BOARD_SEARCH_MAX_PAGE_SIZE,
);
const typeFilters = mapWoTypeFilters(filters.types);
const trimmedSearch = input.search?.trim();
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
@ -224,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
dateTo: date.dateTo,
includeDateless: date.includeDateless,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
types: mapWoTypeFilters(filters.types),
overdue: filters.pastDue || undefined,
dispatchers: mapDispatchers(filters.dispatchers),
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
statuses: mapStatuses(filters.statuses),
@ -261,10 +246,6 @@ export function buildBoardSearchParamsRecord(
return out;
}
export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue";
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(
page = 0,

View file

@ -61,6 +61,18 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
);
}
/** Open = every lifecycle status except Completed (Canceled is not a board status). */
const OPEN_STATUSES = ALL_WIZARD_STATUSES.filter((status) => status !== "Completed");
/** Work Orders filtered to one site's open work orders; `sites` takes the location id. */
export function buildSiteOpenWorkOrdersHref(siteId: string | number): string {
const params = new URLSearchParams({
sites: String(siteId),
statuses: OPEN_STATUSES.join(","),
});
return `/workorders?${params.toString()}`;
}
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
@ -79,6 +91,7 @@ export function parseDashboardDrilldownParams(
const types = parseTypes(params.get("types"));
const dispatchers = parseList(params.get("dispatchers"));
const regions = parseRegions(params.get("regions"));
const sites = [...new Set(parseList(params.get("sites")))];
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
const hasUplift = params.get("hasUplift") === "1";
@ -90,6 +103,7 @@ export function parseDashboardDrilldownParams(
types.length,
dispatchers.length,
regions.length,
sites.length,
upliftStatuses.length,
hasUplift,
].some(Boolean);
@ -122,6 +136,9 @@ export function parseDashboardDrilldownParams(
if (regions.length > 0) {
filters.regions = new Set(regions);
}
if (sites.length > 0) {
filters.sites = new Set(sites);
}
if (upliftStatuses.length > 0) {
filters.upliftStatuses = new Set(upliftStatuses);
}

View file

@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
}
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
if (types.size === 0) {
return true;
}
const overdueMatch = types.has("Overdue") && row.isPastDue;
const typeMatch = types.has(row.type as WOType);
return overdueMatch || typeMatch;
return types.size === 0 || types.has(row.type as WOType);
}
export function filterByBarAndSearch(

View file

@ -10,6 +10,8 @@ export const queryKeys = {
auth: {
all: ["auth"] as const,
session: () => [...queryKeys.auth.all, "session"] as const,
permissions: (userId: string | number) =>
[...queryKeys.auth.all, "permissions", userId] as const,
},
workOrders: {
all: ["workOrders"] as const,
@ -81,6 +83,12 @@ export const queryKeys = {
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
},
completionTemplates: {
all: ["completionTemplates"] as const,
list: (search: string) => [...queryKeys.completionTemplates.all, "list", search] as const,
linkedWorkOrders: (id: number) =>
[...queryKeys.completionTemplates.all, "linkedWorkOrders", id] as const,
},
locations: {
all: ["locations"] as const,
list: (params?: Record<string, unknown>) =>
@ -88,6 +96,8 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const,
openWorkOrders: (id: string | number) =>
[...queryKeys.locations.all, "open-work-orders", id] as const,
},
vendors: {
all: ["vendors"] as const,

View file

@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
return roles.includes("admin") || roles.includes("scheduler");
}
/**
* Site deletion is limited to Admin and Scheduler (the server enforces the DeleteSites team
* permission; this only hides the control from roles that would get a 403).
*/
export function canDeleteSites(userRoles: string | null | undefined): boolean {
return hasUserRole(userRoles, "admin") || hasUserRole(userRoles, "scheduler");
}

View file

@ -0,0 +1,116 @@
import type { Mock } from "vitest";
import type { CompletionTemplateDto } from "@/domain/completion-templates/schemas/completion-template-schema";
export interface ApiMocks {
apiGet: Mock;
apiPost: Mock;
apiPut: Mock;
apiDeleteNoContent: Mock;
}
interface WriteBody {
name: string;
extraSafetyNote: string | null;
procedures: { name: string; description: string }[];
}
export function templateDto(overrides: Partial<CompletionTemplateDto> = {}): CompletionTemplateDto {
return {
id: 12,
name: "Backflow Test Report",
extraSafetyNote: null,
procedures: [],
createdAt: "2026-09-01T13:00:00Z",
updatedAt: null,
createdByName: "Jane Doe",
serviceKey: "backflow",
workOrderType: 2,
templateUrl: "https://files.example/backflow.pdf",
isActive: true,
...overrides,
};
}
export const BACKFLOW = templateDto({
id: 12,
name: "Backflow Test Report",
extraSafetyNote: "Wear gloves",
procedures: [
{ name: "Shut-off", description: "Close valve", sortOrder: 1 },
{ name: "Backflow Procedure", description: "line 1\nline 2", sortOrder: 0 },
],
updatedAt: "2026-09-25T14:00:00Z",
});
export const SCRUBBER = templateDto({
id: 13,
name: "Scrubber Pit Augering",
createdByName: null,
createdAt: "2026-06-01T10:00:00Z",
});
function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) {
return templateDto({
...base,
id,
name: body.name,
extraSafetyNote: body.extraSafetyNote,
procedures: body.procedures.map((procedure, index) => ({ ...procedure, sortOrder: index })),
updatedAt: "2026-09-25T15:00:00Z",
});
}
export const MY_PERMISSIONS_URL = "team-members/me/permissions";
export const TEMPLATE_PERMISSIONS = [
"createCompletionDocTemplates",
"editCompletionDocTemplates",
"deleteCompletionDocTemplates",
];
/** What the effective-permissions endpoint does: answer with keys, fail, or never settle. */
export type PermissionsAnswer = string[] | Error | "pending";
export const permissionsAnswer: { current: PermissionsAnswer } = {
current: TEMPLATE_PERMISSIONS,
};
function answerPermissions(): Promise<unknown> {
const answer = permissionsAnswer.current;
if (answer === "pending") return new Promise(() => undefined);
if (answer instanceof Error) return Promise.reject(answer);
return Promise.resolve({ permissions: answer });
}
/**
* Answers the template endpoints the way the backend contract describes, and the signed-in
* user's effective permissions with `permissionsAnswer.current`.
*/
export function serveTemplates(
api: ApiMocks,
templates: CompletionTemplateDto[],
linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] },
) {
api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => {
if (url === MY_PERMISSIONS_URL) return answerPermissions();
if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked);
const term = options?.searchParams?.search?.toLowerCase() ?? "";
return Promise.resolve(
templates.filter((template) => (template.name ?? "").toLowerCase().includes(term)),
);
});
api.apiPost.mockImplementation((_url: string, body: WriteBody) =>
Promise.resolve(savedDto(99, body)),
);
api.apiPut.mockImplementation((url: string, body: WriteBody) => {
const id = Number(url.split("/").pop());
return Promise.resolve(
savedDto(
id,
body,
templates.find((template) => template.id === id),
),
);
});
api.apiDeleteNoContent.mockResolvedValue(undefined);
}

View file

@ -0,0 +1,297 @@
import { focusManager, QueryClient } from "@tanstack/react-query";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
import { renderWithProviders } from "@/test/test-utils";
import {
BACKFLOW,
MY_PERMISSIONS_URL,
permissionsAnswer,
SCRUBBER,
serveTemplates,
TEMPLATE_PERMISSIONS,
templateDto,
} from "./completion-docs-fixtures";
const api = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
apiDeleteNoContent: vi.fn(),
}));
const auth = vi.hoisted(() => ({ id: "u1", userRoles: "Admin" }));
vi.mock("@/api/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/api")>()),
...api,
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: auth.id, userRoles: auth.userRoles } }),
}));
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
let user = userEvent.setup({ delay: null });
function renderPage() {
return renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
});
}
async function openTemplate(name: string) {
await user.click(await screen.findByRole("row", { name: new RegExp(name) }));
return screen.findByRole("dialog");
}
/** Lets the effective-permissions request finish (or stay pending) before asserting absence. */
async function permissionsRequested() {
await waitFor(() => expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL));
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
}
beforeEach(() => {
user = userEvent.setup({ delay: null });
auth.id = "u1";
auth.userRoles = "Admin";
permissionsAnswer.current = TEMPLATE_PERMISSIONS;
serveTemplates(api, [BACKFLOW, SCRUBBER]);
});
afterEach(() => {
focusManager.setFocused(undefined);
});
describe("Completion Docs page", { timeout: 15_000 }, () => {
it("lists every template with its Document Title, Last Updated and Created By", async () => {
renderPage();
const backflow = await screen.findByRole("row", { name: /Backflow Test Report/ });
const table = screen.getByRole("table", { name: "Completion document templates" });
const headers = within(table)
.getAllByRole("columnheader")
.map((cell) => cell.textContent);
expect(headers.slice(0, 3)).toEqual(["Document Title", "Last Updated", "Created By"]);
expect(within(backflow).getByText("Sep 25, 2026")).toBeInTheDocument();
expect(within(backflow).getByText("Jane Doe")).toBeInTheDocument();
const scrubber = within(table).getByRole("row", { name: /Scrubber Pit Augering/ });
// Never updated: Last Updated falls back to the creation date; no creator shows a dash.
expect(within(scrubber).getByText("Jun 1, 2026")).toBeInTheDocument();
expect(within(scrubber).getByText("—")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Completion Docs" })).toBeInTheDocument();
expect(screen.getAllByText("2 templates")).toHaveLength(2);
});
it("filters by Document Title as the user types and explains an empty result", async () => {
renderPage();
await screen.findByRole("row", { name: /Scrubber Pit Augering/ });
const search = screen.getByRole("searchbox", { name: "Search document title" });
await user.type(search, "backf");
expect(
await screen.findByText("1 template", { selector: "span" }, { timeout: 2000 }),
).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Scrubber Pit Augering/ })).not.toBeInTheDocument();
expect(api.apiGet).toHaveBeenCalledWith("workorders/completion-templates", {
searchParams: { search: "backf" },
});
await user.clear(search);
await user.type(search, "no such document");
expect(
await screen.findByText("No templates found", {}, { timeout: 2000 }),
).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new template.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument();
});
it("reports a failed load without the empty-search message, including after a failed Retry", async () => {
api.apiGet.mockRejectedValue(new Error("Templates are unavailable"));
renderPage();
const retry = await screen.findByRole("button", { name: "Retry" });
expect(screen.getByText("Templates are unavailable")).toBeInTheDocument();
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
const callsBeforeRetry = api.apiGet.mock.calls.length;
await user.click(retry);
await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry));
expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument();
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
});
it("opens a read-only detail panel from a row with every part of the template", async () => {
renderPage();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByText("Document preview")).toBeInTheDocument();
for (const field of ["WO#", "Site Code", "Address", "Service Type"]) {
expect(within(dialog).getByText(field)).toBeInTheDocument();
}
const safety = within(dialog).getByRole("list", { name: "Safety notes" });
expect(within(safety).getAllByRole("listitem")).toHaveLength(4);
expect(within(safety).getByText("Wear gloves")).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
expect(
within(dialog).getByText("Created by Jane Doe · Updated Sep 25, 2026"),
).toBeInTheDocument();
});
it("shows that a template without procedures has none defined yet", async () => {
renderPage();
const dialog = await openTemplate("Scrubber Pit Augering");
expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument();
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
});
});
describe(
"Completion Docs actions follow the server's effective permissions",
{ timeout: 15_000 },
() => {
async function expectEveryAction() {
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument();
}
async function expectNoAction() {
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
}
it("gives every template action to a user the server grants all three", async () => {
renderPage();
await expectEveryAction();
expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL);
});
it("shows every action to a Dispatcher whose overrides grant them", async () => {
auth.userRoles = "Dispatcher";
renderPage();
await expectEveryAction();
});
it("hides Delete when the effective permissions omit it, even for an Admin", async () => {
permissionsAnswer.current = ["createCompletionDocTemplates", "editCompletionDocTemplates"];
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
it("hides Edit from a Scheduler whose edit permission was revoked", async () => {
auth.userRoles = "Scheduler";
permissionsAnswer.current = ["createCompletionDocTemplates"];
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
it("hides add, edit and delete entirely from a user without those permissions", async () => {
auth.userRoles = "Dispatcher";
permissionsAnswer.current = ["createWorkOrders"];
serveTemplates(api, [templateDto()]);
renderPage();
await expectNoAction();
});
it("hides every action while the permissions are still loading", async () => {
permissionsAnswer.current = "pending";
renderPage();
await expectNoAction();
});
it("hides every action when the permissions fail to load, and still lists templates", async () => {
permissionsAnswer.current = new Error("Network down");
renderPage();
expect(await screen.findByRole("row", { name: /Scrubber Pit Augering/ })).toBeInTheDocument();
await expectNoAction();
});
it("hides the actions again when a later permissions refresh fails", async () => {
renderPage();
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
permissionsAnswer.current = new Error("Network down");
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(),
);
});
it("picks up a changed override when the window regains focus", async () => {
permissionsAnswer.current = [];
renderPage();
await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
permissionsAnswer.current = ["createCompletionDocTemplates"];
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
});
it("never reuses one signed-in user's permissions for the next", async () => {
// Keeps the first answer cached after unmount, so only the per-user key can keep it out.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
const first = renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
queryClient,
});
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
first.unmount();
auth.id = "u2";
permissionsAnswer.current = "pending";
renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
queryClient,
});
await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
});
},
);

View file

@ -0,0 +1,309 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HTTPError } from "ky";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
import { renderWithProviders } from "@/test/test-utils";
import { BACKFLOW, SCRUBBER, serveTemplates } from "./completion-docs-fixtures";
const api = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
apiDeleteNoContent: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/api")>()),
...api,
}));
vi.mock("react-toastify", () => ({ toast }));
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => navigate,
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }),
}));
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
let user = userEvent.setup({ delay: null });
function renderPage() {
return renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
});
}
async function openNewTemplate() {
renderPage();
await screen.findByRole("row", { name: /Backflow Test Report/ });
await user.click(await screen.findByRole("button", { name: /Add Template/ }));
return screen.findByRole("dialog");
}
async function openTemplate(name: string) {
renderPage();
await user.click(await screen.findByRole("row", { name: new RegExp(name) }));
return screen.findByRole("dialog");
}
function previewHeadings(dialog: HTMLElement) {
const preview = within(dialog).getByTestId("completion-template-preview");
return within(preview)
.getAllByRole("heading", { level: 3 })
.map((heading) => heading.textContent);
}
/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */
function unprocessable(body: { code: string; message: string }, message = body.message) {
const error = new HTTPError(
new Response(null, { status: 422 }),
new Request("http://api/workorders/completion-templates"),
{} as never,
);
error.data = body;
error.message = message;
return error;
}
async function addProcedure(dialog: HTMLElement, index: number, name: string) {
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
await user.type(within(dialog).getByLabelText(`Procedure ${index} name`), name);
}
beforeEach(() => {
user = userEvent.setup({ delay: null });
vi.clearAllMocks();
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] });
});
describe("Add Template", { timeout: 15_000 }, () => {
it("blocks saving without a Document Title and says why", async () => {
const dialog = await openNewTemplate();
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveFocus();
expect(api.apiPost).not.toHaveBeenCalled();
});
it("shows the fixed fields read-only and lets the author add then remove one safety note", async () => {
const dialog = await openNewTemplate();
const safety = within(dialog).getByRole("list", { name: "Safety notes" });
expect(within(safety).getAllByRole("listitem")).toHaveLength(3);
expect(within(dialog).getByText("WO#")).toBeInTheDocument();
expect(within(dialog).getAllByRole("textbox")).toHaveLength(1);
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull();
await user.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit");
expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent(
"Mind the pit",
);
await user.click(within(dialog).getByRole("button", { name: "Remove safety note" }));
expect(within(dialog).queryByLabelText("Extra safety note")).toBeNull();
expect(within(dialog).getByTestId("completion-template-preview")).not.toHaveTextContent(
"Mind the pit",
);
});
it("adds and removes procedures and previews them in editor order", async () => {
const dialog = await openNewTemplate();
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
await addProcedure(dialog, 1, "Isolate");
await addProcedure(dialog, 2, "Inspect");
await addProcedure(dialog, 3, "Restore");
expect(previewHeadings(dialog)).toEqual(["Isolate", "Inspect", "Restore"]);
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 2" }));
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore"]);
expect(within(dialog).getByLabelText("Procedure 2 name")).toHaveValue("Restore");
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore", "Procedure 3"]);
});
it("saves the title, note and procedures in order, then confirms with a toast", async () => {
const dialog = await openNewTemplate();
await user.type(
within(dialog).getByRole("textbox", { name: /Document Title/ }),
" PRV Check ",
);
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
await user.type(within(dialog).getByLabelText("Extra safety note"), "Gloves");
await addProcedure(dialog, 1, "Open");
await user.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b");
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
await addProcedure(dialog, 3, "Close");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template added"));
expect(api.apiPost).toHaveBeenCalledWith("workorders/completion-templates", {
name: "PRV Check",
extraSafetyNote: "Gloves",
procedures: [
{ name: "Open", description: "a\nb" },
{ name: "Close", description: "" },
],
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("shows the server's blank-title rejection on the title field", async () => {
api.apiPost.mockRejectedValue(
unprocessable({ code: "TitleRequired", message: "Document Title is required." }),
);
const dialog = await openNewTemplate();
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("reports any other rejected value in a toast and keeps the form open", async () => {
const message = "Procedure name must be 200 characters or fewer.";
api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message));
const dialog = await openNewTemplate();
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
expect(within(dialog).queryByText("Document Title is required")).toBeNull();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
describe("Edit template", { timeout: 15_000 }, () => {
it("Cancel discards unsaved changes and returns to the original read-only values", async () => {
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
expect(within(dialog).getByText("Live preview")).toBeInTheDocument();
const title = within(dialog).getByRole("textbox", { name: /Document Title/ });
await user.clear(title);
await user.type(title, "Renamed");
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 1" }));
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
expect(within(dialog).getAllByText("Backflow Test Report").length).toBeGreaterThan(0);
expect(within(dialog).queryByText("Renamed")).not.toBeInTheDocument();
expect(previewHeadings(dialog)).toEqual(["Backflow Procedure", "Shut-off"]);
expect(api.apiPut).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveValue(
"Backflow Test Report",
);
});
it("saves an edit, keeps the legacy fields and confirms with a toast", async () => {
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
expect(api.apiPut).toHaveBeenCalledWith("workorders/completion-templates/12", {
serviceKey: "backflow",
workOrderType: 2,
templateUrl: "https://files.example/backflow.pdf",
isActive: true,
name: "Backflow Test Report v2",
extraSafetyNote: "Wear gloves",
procedures: [
{ name: "Backflow Procedure", description: "line 1\nline 2" },
{ name: "Shut-off", description: "Close valve" },
],
});
expect(
await within(dialog).findByText("Created by Jane Doe · Updated Sep 25, 2026"),
).toBeInTheDocument();
});
it.each([
[
"Escape",
async () => {
// The disabled Save button drops focus; Escape is pressed from inside the form.
screen.getByRole("textbox", { name: /Document Title/ }).focus();
await user.keyboard("{Escape}");
},
],
["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))],
])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => {
let finishSave: () => void = () => {};
const respond = api.apiPut.getMockImplementation()!;
api.apiPut.mockImplementation(
(...args: unknown[]) =>
new Promise((resolve) => {
finishSave = () => resolve(respond(...args));
}),
);
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
await dismiss();
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
finishSave();
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("Delete template", { timeout: 15_000 }, () => {
it("states how many open work orders depend on it and links to exactly those", async () => {
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 2, workOrderIds: [101, 205] });
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
expect(
await within(confirm).findByText(
/It's still linked to 2 open work orders through its Service/,
),
).toBeInTheDocument();
expect(api.apiDeleteNoContent).not.toHaveBeenCalled();
await user.click(within(confirm).getByRole("button", { name: "View linked work orders" }));
expect(navigate).toHaveBeenCalledWith("/workorders?ids=101,205");
});
it("still asks first when nothing depends on it, then deletes with a toast", async () => {
const dialog = await openTemplate("Scrubber Pit Augering");
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
expect(
await within(confirm).findByText(
'This will permanently remove "Scrubber Pit Augering". This cannot be undone.',
),
).toBeInTheDocument();
expect(within(confirm).queryByRole("button", { name: "View linked work orders" })).toBeNull();
await user.click(within(confirm).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template deleted"));
expect(api.apiDeleteNoContent).toHaveBeenCalledWith("workorders/completion-templates/13");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});

View file

@ -0,0 +1,286 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { HTTPError } from "ky";
import { Route, Routes, useLocation } from "react-router";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LocationsListPage from "@/app/(protected)/locations/index";
import { normalizeApiRequestError } from "@/api/api-error";
import type { Location, LocationListItem } from "@/domain/locations/types/location";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
import { renderWithProviders } from "@/test/test-utils";
const getList = vi.fn();
const getById = vi.fn();
const getOpenWorkOrders = vi.fn();
const deleteSite = vi.fn();
vi.mock("@/domain/locations/api/locations-api", () => ({
locationsApi: {
getList: (...args: unknown[]) => getList(...args),
getById: (...args: unknown[]) => getById(...args),
getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args),
delete: (...args: unknown[]) => deleteSite(...args),
update: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
useAccountsList: () => ({
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
isLoading: false,
}),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const site: Location = {
id: 1,
name: "DAL1",
clientName: "Acme Logistics",
accountId: 7,
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",
contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }],
};
const row: LocationListItem = {
id: 1,
name: "DAL1",
clientName: "Acme Logistics",
sitePhone: "",
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",
zipCode: "",
phone: "(421) 433-0032",
contact: "Jane",
contactEmail: "",
status: "Active",
accountId: 7,
contacts: site.contacts,
};
function WorkOrdersProbe() {
const location = useLocation();
return <div data-testid="work-orders-url">{`${location.pathname}${location.search}`}</div>;
}
function renderAs(userRoles: string) {
const auth = { user: { userRoles } } as unknown as AuthContextValue;
return renderWithProviders(
<AuthContext.Provider value={auth}>
<Routes>
<Route path="/locations" element={<LocationsListPage />} />
<Route path="/workorders" element={<WorkOrdersProbe />} />
</Routes>
</AuthContext.Provider>,
{ withAuth: false, route: "/locations" },
);
}
async function openPanel(): Promise<HTMLElement> {
fireEvent.click(await screen.findByRole("button", { name: "DAL1" }));
const panel = screen.getByRole("region", { name: "Site details" });
await within(panel).findByRole("button", { name: "Edit" });
return panel;
}
function forbidden(): HTTPError {
const body = { status: "Error", message: "You are not allowed to delete sites." };
const error = new HTTPError(
new Response(JSON.stringify(body), { status: 403 }),
new Request("https://api.example.test/api/locations/1"),
{} as never,
);
error.data = body;
return normalizeApiRequestError(error) as HTTPError;
}
describe("Delete a site from the detail panel", () => {
beforeEach(() => {
for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) {
mock.mockReset();
}
getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 });
getById.mockResolvedValue(site);
});
it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => {
renderAs(role);
const panel = await openPanel();
expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
it("hides Delete from a Dispatcher and removes the old row delete control", async () => {
renderAs("Dispatcher");
const panel = await openPanel();
expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument();
});
it("warns with the open work-order count and links to exactly those work orders", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] });
renderAs("Scheduler");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(
await within(dialog).findByText(
"This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.",
),
).toBeInTheDocument();
expect(getOpenWorkOrders).toHaveBeenCalledWith("1");
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
expect(await screen.findByTestId("work-orders-url")).toHaveTextContent(
"/workorders?ids=101,205,318",
);
expect(deleteSite).not.toHaveBeenCalled();
});
it.each([
["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }],
["an open count with no ids", { count: 4, workOrderIds: [] }],
])("links to the site's open work-order board when there are %s", async (_label, result) => {
getOpenWorkOrders.mockResolvedValue(result);
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`));
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
const url = new URL(
(await screen.findByTestId("work-orders-url")).textContent ?? "",
"https://app.test",
);
expect(url.pathname).toBe("/workorders");
expect(url.searchParams.get("ids")).toBeNull();
expect(url.searchParams.get("sites")).toBe("1");
expect(url.searchParams.get("statuses")?.split(",")).toEqual([
"Incomplete",
"Pending",
"Scheduled",
"En Route",
"On Site",
"In Progress",
"Rescheduled",
"Pending Quote",
]);
});
it("confirms without a warning when nothing is open, deletes and closes the panel", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
deleteSite.mockResolvedValue(undefined);
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(
await within(dialog).findByText(
"This will permanently remove DAL1 from Sites. This cannot be undone.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "View open work orders" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1"));
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
);
});
it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => {
getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error"));
getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] });
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Couldn't check this site for open work orders. Try again before deleting it.",
);
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Try again" }));
expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled();
expect(deleteSite).not.toHaveBeenCalled();
});
it("holds Delete on reopen until the fresh open work-order count replaces the cached one", async () => {
let resolveRecheck: (value: { count: number; workOrderIds: number[] }) => void = () => {};
getOpenWorkOrders.mockResolvedValueOnce({ count: 0, workOrderIds: [] });
getOpenWorkOrders.mockReturnValueOnce(
new Promise((resolve) => {
resolveRecheck = resolve;
}),
);
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
let dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await within(dialog).findByText(
"This will permanently remove DAL1 from Sites. This cannot be undone.",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Delete this site?" })).not.toBeInTheDocument(),
);
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await waitFor(() => expect(getOpenWorkOrders).toHaveBeenCalledTimes(2));
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
expect(within(dialog).getByLabelText("Checking open work orders")).toBeInTheDocument();
resolveRecheck({ count: 1, workOrderIds: [101] });
expect(await within(dialog).findByText(/It still has 1 open work order —/)).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
expect(deleteSite).not.toHaveBeenCalled();
});
it("keeps the site and shows the server's refusal when the API answers 403", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
deleteSite.mockRejectedValue(forbidden());
const toastError = vi.spyOn(toast, "error");
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await within(dialog).findByText(/This cannot be undone/);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."),
);
expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument();
expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument();
});
});

View file

@ -235,17 +235,4 @@ describe("Sites page detail panel", () => {
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
);
});
it("row delete asks to confirm removing the site without opening the panel", async () => {
renderWithProviders(<LocationsListPage />, { withAuth: false });
fireEvent.click(await screen.findByRole("button", { name: "Delete site DAL1" }));
const confirm = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(
within(confirm).getByText(
"This will permanently remove DAL1 from Sites. This cannot be undone.",
),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument();
});
});

View file

@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeEnabled();
fireEvent.click(revoke);
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
expect(
screen.getByText("Uplifts you approve are archived here for reference."),

View file

@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));

View file

@ -0,0 +1,237 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type {
UpliftQueueItem,
UpliftsQueueParams,
UpliftsQueueResult,
} from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
import { renderWithProviders } from "@/test/test-utils";
const queueState = vi.hoisted(() => ({
byStatus: {} as Record<string, UpliftsQueueResult | undefined>,
errorByStatus: {} as Record<string, Error | undefined>,
requestedStatuses: [] as Array<string | undefined>,
}));
const baseItem: UpliftQueueItem = {
id: 1,
status: "Pending",
currentNTE: 500,
requestedNTE: 750,
delta: 250,
vendorReason: "Second hydro-jetting pass on the adjoining line",
requestedAt: "2026-06-21T10:05:00Z",
requestedByVendorName: "Alyssa Dispatcher",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: "doc-9",
evidenceFileName: "quote.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 1024,
dispatchNumber: "DSP-1",
poNumber: "PO-1",
vendorCompanyName: "Drain Cleaning Bros",
workOrderId: 10,
dispatchId: 20,
woNumber: "WO-25603",
site: "CHI2",
serviceName: "Drains",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 1,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const newestRejection: UpliftQueueItem = {
...baseItem,
id: 31,
status: "Rejected",
woNumber: "WO-31",
decidedAt: "2026-06-21T16:40:00Z",
decidedByName: "Adam Moussa",
decisionNote: "Outside this work order's scope — raise a separate reactive WO.",
canDecide: false,
};
const olderRejection: UpliftQueueItem = {
...baseItem,
id: 30,
status: "Rejected",
woNumber: "WO-30",
requestedByVendorName: "Derek Dispatcher",
decidedAt: "2026-05-26T15:05:00Z",
decidedByName: "Grace Admin",
decisionNote: "Quote is above market for this scope.",
canDecide: false,
};
const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" };
const approvedItem: UpliftQueueItem = {
...baseItem,
id: 50,
status: "Approved",
woNumber: "WO-APPROVED",
decidedAt: "2026-06-01T09:00:00Z",
decidedByName: "Adam Moussa",
};
function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult {
return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null };
}
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: (params: UpliftsQueueParams) => {
queueState.requestedStatuses.push(params.status);
const data = queueState.byStatus[params.status ?? ""];
const error = queueState.errorByStatus[params.status ?? ""] ?? null;
return {
data,
isLoading: data === undefined && error === null,
isFetching: false,
error,
refetch: vi.fn(),
};
},
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
async function openRejectedTab() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ }));
return screen.getByRole("table", { name: "rejected uplift requests" });
}
describe("Uplift Approvals rejected tab", () => {
// Load the page module once up front so the first test's budget is not spent on a
// cold import of the whole route.
beforeAll(async () => {
await import("@/app/(protected)/uplifts/index");
}, 60_000);
beforeEach(() => {
queueState.requestedStatuses = [];
queueState.errorByStatus = {};
queueState.byStatus = {
Pending: page([pendingItem]),
Approved: page([approvedItem]),
Rejected: page([newestRejection, olderRejection], 12),
};
});
it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ });
expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument();
expect(queueState.requestedStatuses).toContain("Rejected");
});
it("never claims a count for a queue that is still loading or failed to load", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Forbidden");
queueState.byStatus.Approved = undefined;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedPill = screen.getByLabelText("rejected uplift count unavailable");
expect(rejectedPill).toHaveTextContent("—");
expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—");
expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument();
expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument();
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
});
it("keeps the count and pager for kept rows when a rejected refetch fails", async () => {
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).getAllByRole("row")).toHaveLength(3);
expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByText(/of 12/)).toBeInTheDocument();
});
it("shows the load error instead of an empty archive when the rejected queue fails", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument();
expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument();
expect(screen.queryByText(/of 0/)).not.toBeInTheDocument();
});
it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => {
const table = await openRejectedTab();
const dataRows = within(table).getAllByRole("row").slice(1);
expect(dataRows).toHaveLength(2);
expect(dataRows[0]).toHaveTextContent("WO-31");
expect(dataRows[1]).toHaveTextContent("WO-30");
expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument();
expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument();
});
it("shows who requested, who rejected, when, and the rejection reason inline", async () => {
const table = await openRejectedTab();
for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) {
expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument();
}
const [, firstRow] = within(table).getAllByRole("row");
expect(firstRow).toHaveTextContent("Alyssa Dispatcher");
expect(firstRow).toHaveTextContent("Adam Moussa");
expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt));
expect(
within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."),
).toBeVisible();
});
it("offers no action on a rejected uplift", async () => {
const table = await openRejectedTab();
expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) {
expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument();
}
});
it("shows an icon and the empty copy when nothing has been rejected", async () => {
queueState.byStatus.Rejected = page([]);
const table = await openRejectedTab();
expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument();
expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument();
expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
canContinueWizardStep1,
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
describe("Overdue work order type", () => {
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
const onPatch = vi.fn();
const { rerender } = render(
<WizardStepTypeSchedule
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({ type: "Overdue", severity: null }),
);
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
rerender(
<WizardStepTypeSchedule
draft={overdueDraft}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
});
it("creates the work order with the Overdue enum and no severity", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "Overdue",
severity: 2,
locationId: "1",
siteCode: "BK5",
});
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
expect(payload.severity ?? null).toBeNull();
});
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
expect(onSave).toHaveBeenCalledWith("Overdue", null);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("reads the Overdue enum back from the board as the Overdue type", () => {
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
expect(row.type).toBe("Overdue");
});
it("closes with the completion document, not photos", () => {
const base = {
id: "1",
dispatcherId: "sarah",
dispatcherName: "Sarah Chen",
company: "Gateway Plumbing",
vendorId: "",
tech: "",
completedDate: "2026-09-24",
woNumber: "24971",
pm: "Backflow Preventers",
mediaCount: 0,
};
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
).toEqual(["Completion document"]);
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
).toEqual([]);
const row = {
id: "1",
type: "Overdue",
pm: "Backflow Preventers",
docStatus: "No",
rowVersion: "1",
};
render(
<SlideOverCompletionTab
row={row as WorkOrderTableRow}
isReadOnly={false}
media={[]}
onOpenCompletionDoc={vi.fn()}
/>,
);
expect(screen.getByText("Completion Document")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
});
it("keeps a separate Past Due status toggle in the advanced filters", () => {
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
const setDraft = vi.fn(
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
draft = update(draft);
},
);
render(
<AdvancedSearchFilterSections
draft={draft}
setDraft={setDraft as never}
toggleIn={vi.fn()}
setMany={vi.fn()}
sites={[]}
users={[]}
pmTypes={[]}
vendorTechs={[]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
expect(draft.pastDue).toBe(true);
expect(draft.types.size).toBe(0);
expect(buildAdvancedFilterChips(draft)).toContainEqual(
expect.objectContaining({ group: "Status", value: "Past Due" }),
);
});
});

View file

@ -32,6 +32,15 @@ describe("navGroups", () => {
expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services");
});
it("lists Completion Docs after Services in the DATA MANAGEMENT group", () => {
const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT");
expect(dataManagement?.items.map((item) => item.label)).toEqual([
"Services",
"Completion Docs",
]);
expect(getActiveNavPath("/completion-docs")).toBe("/completion-docs");
});
it("does not include the approvals group by default", () => {
expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false);
});

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
mapCompletionTemplate,
toCompletionTemplateInput,
} from "@/domain/completion-templates/mappers/completion-template-mapper";
import { completionTemplateDtoSchema } from "@/domain/completion-templates/schemas/completion-template-schema";
describe("mapCompletionTemplate", () => {
it("orders procedures by sortOrder and shows createdAt when never updated", () => {
const template = mapCompletionTemplate(
completionTemplateDtoSchema.parse({
id: 4,
name: "PRV",
extraSafetyNote: " ",
procedures: [
{ id: 2, name: "Second", description: "b", sortOrder: 1 },
{ id: 1, name: "First", description: "a", sortOrder: 0 },
],
createdAt: "2026-09-01T00:00:00Z",
updatedAt: null,
createdByName: null,
}),
);
expect(template.procedures.map((procedure) => procedure.name)).toEqual(["First", "Second"]);
expect(template.lastUpdatedAt).toBe("2026-09-01T00:00:00Z");
expect(template.extraSafetyNote).toBeNull();
expect(template.createdByName).toBeNull();
});
it("rejects a response that is not a template", () => {
expect(completionTemplateDtoSchema.safeParse({ name: "no id" }).success).toBe(false);
});
});
describe("toCompletionTemplateInput", () => {
it("trims fields, clears a blank note and drops procedures left entirely blank", () => {
expect(
toCompletionTemplateInput({
name: " Title ",
extraSafetyNote: " ",
procedures: [
{ name: " ", description: "" },
{ name: "", description: " steps " },
],
}),
).toEqual({
name: "Title",
extraSafetyNote: null,
procedures: [{ name: "", description: "steps" }],
});
});
});

View file

@ -3,6 +3,7 @@ import {
mapLocation,
mapLocationListItem,
mapLocationToBackend,
mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
describe("location contacts hydration", () => {
@ -147,3 +148,16 @@ describe("mapLocation site fields", () => {
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
});
});
describe("mapSiteOpenWorkOrders", () => {
it("keeps the server count and drops invalid ids", () => {
expect(mapSiteOpenWorkOrders({ count: 240, workOrderIds: [101, "x", -3, 205] })).toEqual({
count: 240,
workOrderIds: [101, 205],
});
expect(mapSiteOpenWorkOrders({ Count: 0, WorkOrderIds: [] })).toEqual({
count: 0,
workOrderIds: [],
});
});
});

View file

@ -78,11 +78,14 @@ describe("servicesApi", () => {
});
});
it("does not request services for the unsupported Overdue type", async () => {
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
it("lists the PM catalog for the Overdue type", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
await servicesApi.getActiveForWorkOrderType("Overdue");
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: 2 },
});
});
it("creates, updates, and deactivates through the registry routes", async () => {

View file

@ -0,0 +1,40 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { createAppQueryClient } from "@/lib/query/query-client";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error, success: vi.fn() },
}));
afterEach(() => {
vi.restoreAllMocks();
toastMocks.error.mockReset();
});
describe("useUpliftsQueue", () => {
it("does not show the global error toast, since the approvals page reports the failure inline", async () => {
vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue"));
// The real app query client runs the QueryCache's global onError handler, which
// toasts unless the query opts out.
const queryClient = createAppQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false } });
const { result } = renderHook(
() => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
},
);
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
expect(toastMocks.error).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import {
buildSiteOpenWorkOrdersHref,
parseDashboardDrilldownParams,
} from "@/domain/work-orders/utils/dashboard-drilldown-params";
import {
avetaPendingDrilldownSearch,
unassignedDrilldownSearch,
@ -102,6 +106,39 @@ describe("parseDashboardDrilldownParams", () => {
expect(cleared.dispatchers.size).toBe(0);
});
it("turns the site open work-order link into that site's open, all-weeks board search", () => {
const href = buildSiteOpenWorkOrdersHref(12);
const filters = parseDashboardDrilldownParams(new URL(href, "https://app.test").searchParams);
expect(filters?.sites).toEqual(new Set(["12"]));
expect(filters?.workOrderIds.size).toBe(0);
const params = buildResolvedBoardSearchParams({
advApplied: filters,
search: "",
page: 0,
pageSize: 200,
sortBy: "",
sortDir: "",
locations: [{ id: 12, name: "DAL1", siteCode: "DAL1" }],
vendors: [],
});
expect(params).toMatchObject({
sites: ["DAL1"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
});
});
it("keeps an exact id set ahead of a site filter", () => {
const filters = parseDashboardDrilldownParams(new URLSearchParams({ ids: "4,9", sites: "12" }));
expect(filters?.workOrderIds).toEqual(new Set([4, 9]));
expect(filters?.sites.size).toBe(0);
});
it("rejects partial or malformed date ranges", () => {
expect(
parseDashboardDrilldownParams(

View file

@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => {
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue);
expect(WORK_ORDER_TYPE_API.Overdue).toBe(8);
expect(mapWizardTypeToApi("")).toBeNull();
});

View file

@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
expect(params.overdue).toBeUndefined();
});
it("maps Overdue bar filter to overdue=true without types", () => {
it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => {
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
expect(params.overdue).toBe(true);
expect(params.types).toBeUndefined();
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
expect(params.overdue).toBeUndefined();
});
it("maps Overdue with real types as OR (types + overdue)", () => {
it("maps Overdue with other types as a plain type union", () => {
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
expect(params.overdue).toBe(true);
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
expect(params.overdue).toBeUndefined();
});
it("omits types when the set is empty", () => {
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
});
it("maps Overdue-only advanced filter to overdue=true without types", () => {
it("maps the Overdue type advanced filter to types without the past-due flag", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
types: new Set(["Overdue"]),
},
});
expect(params.overdue).toBe(true);
expect(params.types).toBeUndefined();
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
expect(params.overdue).toBeUndefined();
});
it("maps Overdue with real types as OR (types + overdue)", () => {
it("maps the Past Due status toggle to overdue=true alongside types", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
types: new Set(["PM", "Overdue"]),
types: new Set(["PM"]),
pastDue: true,
},
});
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);

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