diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
index 61410e8e..2833ec24 100644
Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ
diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
index c73fc9e3..84e81461 100644
Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
index 0c695940..80b51f39 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
index 0b081cba..8ff483ba 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
index 18302e36..50f3c6df 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
index a2f46763..ede6fff5 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
index 1ba120c7..19477e67 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
index 869457df..112e0ec4 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
index 6a2eb6e5..c2ae4657 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
index 53582910..61338ef2 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
index c9f0986a..d1aa8df6 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
index 8382b445..ca02832a 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
index 5f3e96ae..71bbcf52 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
index 534e8552..1a45825b 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
index 117f2d30..65075a30 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
index b6349d96..0b402d6e 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png
index cae7003a..c436a8d8 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ
diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
index 1d727318..983bd92f 100644
Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ
diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts
index b5da6165..8b4009bf 100644
--- a/src/api/api-paths.ts
+++ b/src/api/api-paths.ts
@@ -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}`,
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx
new file mode 100644
index 00000000..4129b919
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+export function CompletionTemplateAddLink({
+ onClick,
+ children,
+}: {
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx b/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx
new file mode 100644
index 00000000..63aeb2b5
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx
@@ -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 (
+
+ —
+
+ );
+ }
+ return (
+
+
+ {initials(name)}
+
+
+ {name}
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx b/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx
new file mode 100644
index 00000000..b87b4978
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-detail.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function ProcedureList({ template }: { template: CompletionTemplate }) {
+ if (template.procedures.length === 0) {
+ return (
+
+ {COMPLETION_TEMPLATE_COPY.noProcedureDetail}
+
+ );
+ }
+ return (
+
+ {template.procedures.map((procedure, index) => (
+
+
+ {procedure.name || `Procedure ${index + 1}`}
+
+ {descriptionLines(procedure.description).map((line, lineIndex) => (
+
+ {line}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+/** Everything a template holds, read-only. */
+export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) {
+ return (
+
+
Completion Document
+
+
+ {template.name || "—"}
+
+
+
+
+ Safety Notes
+
+
+
+
+
+
+ {formatTemplateDate(template.lastUpdatedAt)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx b/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx
new file mode 100644
index 00000000..21b7dfe6
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx
@@ -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 (
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {mode === "create" ? : }
+ {mode === "create"
+ ? COMPLETION_TEMPLATE_COPY.addTemplate
+ : COMPLETION_TEMPLATE_COPY.saveChanges}
+
+
+ );
+ }
+ return (
+
+ {permissions.canDelete && (
+
+ Delete
+
+ )}
+ {permissions.canEdit && (
+
+ Edit
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts b/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts
new file mode 100644
index 00000000..5455dd7e
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts
@@ -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)}`,
+ };
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx b/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx
new file mode 100644
index 00000000..baaab431
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx
@@ -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 (
+
+
+ {heading.title}
+
+
+
+
+
+
+ {dialog.isEditing ? (
+
+ ) : (
+ template !== null &&
+ )}
+
+
+
+ void dialog.submit()}
+ onEdit={dialog.startEditing}
+ onDelete={() => setConfirmingDelete(true)}
+ />
+ {template !== null && permissions.canDelete && (
+ setConfirmingDelete(false)}
+ onDeleted={onClose}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-form.tsx b/src/app/(protected)/completion-docs/_components/completion-template-form.tsx
new file mode 100644
index 00000000..07e77b68
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-form.tsx
@@ -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;
+};
+
+function TitleField({ form }: CompletionTemplateFormProps) {
+ return (
+ (
+
+
+
+ )}
+ />
+ );
+}
+
+/** The editable template: title, fixed references, the optional note and procedures. */
+export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) {
+ return (
+
+
Completion Document
+
+
+
+ Safety Notes
+
+
+
+
+ Procedures
+
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx
new file mode 100644
index 00000000..df596fd4
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx
@@ -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 (
+
+
+
+ {isEditing ? "Live preview" : "Document preview"}
+
+
+ );
+}
+
+function PreviewTitle({ name }: { name: string }) {
+ const title = name.trim();
+ return (
+ <>
+
+ {title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument}
+
+
+ {AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix}
+
+ >
+ );
+}
+
+function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
+ if (procedures.length === 0) {
+ return (
+
+ {COMPLETION_TEMPLATE_COPY.noProcedurePreview}
+
+ );
+ }
+ return (
+ <>
+ {procedures.map((procedure, index) => (
+
+
+ {procedure.name.trim() || `Procedure ${index + 1}`}
+
+ {descriptionLines(procedure.description).map((line, lineIndex) => (
+
+ {line}
+
+ ))}
+
+ ))}
+ >
+ );
+}
+
+/** 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 (
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx
new file mode 100644
index 00000000..d35a2ec4
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx
@@ -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;
+};
+
+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 (
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ );
+}
+
+/** 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 (
+
+ {fields.map((field, index) => (
+
+
+
+ Procedure {index + 1}
+
+ remove(index)}
+ />
+
+
+
+ ))}
+
append({ name: "", description: "" })}>
+ + Add procedure
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx
new file mode 100644
index 00000000..f1d0b1b5
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx
@@ -0,0 +1,22 @@
+import { X } from "lucide-react";
+
+export function CompletionTemplateRemoveButton({
+ label,
+ onClick,
+}: {
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx
new file mode 100644
index 00000000..fad8cac5
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx
@@ -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;
+};
+
+/** 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 (
+ setNote("")}>
+ + Add safety note
+
+ );
+ }
+ return (
+
+
(
+
+ )}
+ />
+
+ setNote(null)} />
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx
new file mode 100644
index 00000000..6dd48810
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) {
+ return (
+
+
+ {children}
+
+ {hint !== undefined && (
+
+ {hint}
+
+ )}
+
+ );
+}
+
+export function AutoPopulatedFields() {
+ return (
+
+
Auto-populated fields
+
+ {AUTO_POPULATED_FIELDS.map((field) => (
+
+ {field}
+
+ ))}
+
+
+ );
+}
+
+/** The standard bullets every generated document prints; never editable per template. */
+export function StandardSafetyBullets({ extra }: { extra?: string | null }) {
+ return (
+
+ {SAFETY_BULLETS.map((bullet) => (
+
+ {bullet}
+
+ ))}
+ {Boolean(extra) && (
+ {extra}
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx
new file mode 100644
index 00000000..5756ad64
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx
@@ -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 (
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
+ >
+
+
+ );
+}
+
+function EmptyRow() {
+ return (
+
+
+
+
+
+ {COMPLETION_TEMPLATE_COPY.emptySearchTitle}
+
+
+ {COMPLETION_TEMPLATE_COPY.emptySearchHint}
+
+
+
+
+ );
+}
+
+function TemplateRow({
+ template,
+ canEdit,
+ onView,
+ onEdit,
+}: Omit & {
+ template: CompletionTemplate;
+}) {
+ return (
+ onView(template)} sx={{ cursor: "pointer", height: 52 }}>
+
+
+
+
+
+ {template.name}
+
+
+
+ {formatTemplateDate(template.lastUpdatedAt)}
+
+
+
+
+
+
+ onView(template)} />
+ {canEdit && onEdit(template)} />}
+
+
+
+ );
+}
+
+export function CompletionTemplatesTable({
+ templates,
+ isLoading,
+ hasError,
+ canEdit,
+ onView,
+ onEdit,
+}: CompletionTemplatesTableProps) {
+ return (
+
+
+
+ Document Title
+ Last Updated
+ Created By
+
+ Actions
+
+
+
+
+ {isLoading && }
+ {!isLoading && !hasError && templates.length === 0 && }
+ {templates.map((template) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx
new file mode 100644
index 00000000..6ec9a5e4
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx
@@ -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;
+}) {
+ if (linked.isPending) {
+ return (
+
+
+
+ Checking linked work orders…
+
+
+ );
+ }
+ if (linked.isError) {
+ return (
+
+ Couldn't check which work orders use this template. Close this and try again.
+
+ );
+ }
+ return {deleteMessage(name, linked.data)} ;
+}
+
+/** 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 (
+
+ Delete this template?
+
+
+
+
+
+ {linkedIds.length > 0 && (
+ void navigate(buildWorkOrderIdsHref(linkedIds))}>
+ View linked work orders
+
+ )}
+
+ Delete
+
+
+
+ );
+}
diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts
new file mode 100644
index 00000000..2c0e731c
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts
@@ -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>["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(
+ template === null ? "create" : initialMode,
+ );
+ const form = useForm({
+ 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,
+ };
+}
diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts
new file mode 100644
index 00000000..98534285
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts
@@ -0,0 +1,36 @@
+import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
+import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
+import { useAuthContext } from "@/providers/auth-context";
+
+const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"];
+
+export interface CompletionTemplatePermissions {
+ canCreate: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+}
+
+function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
+ const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
+ return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
+}
+
+/**
+ * The signed-in user's session carries roles, not per-user permission overrides, so each key
+ * resolves to the role default the Team Members screen and the server policy start from.
+ */
+export function completionTemplatePermissions(
+ userRoles: string | null | undefined,
+): CompletionTemplatePermissions {
+ const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)]));
+ return {
+ canCreate: granted.has("createCompletionDocTemplates"),
+ canEdit: granted.has("editCompletionDocTemplates"),
+ canDelete: granted.has("deleteCompletionDocTemplates"),
+ };
+}
+
+export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
+ const { user } = useAuthContext();
+ return completionTemplatePermissions(user?.userRoles);
+}
diff --git a/src/app/(protected)/completion-docs/index.tsx b/src/app/(protected)/completion-docs/index.tsx
new file mode 100644
index 00000000..a17139c3
--- /dev/null
+++ b/src/app/(protected)/completion-docs/index.tsx
@@ -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(null);
+ const templates = filtered.data ?? [];
+
+ return (
+
+
+
+ {permissions.canCreate && (
+ setDialog({ template: null, mode: "create" })}
+ className="gap-1.5"
+ >
+ {COMPLETION_TEMPLATE_COPY.addTemplate}
+
+ )}
+
+ }
+ />
+
+ {filtered.error !== null && (
+ void filtered.refetch()}
+ />
+ )}
+
+
+ setDialog({ template, mode: "view" })}
+ onEdit={(template) => setDialog({ template, mode: "edit" })}
+ />
+
+
+
+ {templateCountLabel(templates.length)}
+
+
+
+ {dialog !== null && (
+ setDialog(null)}
+ onSaved={(template) => setDialog({ template, mode: "view" })}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx
deleted file mode 100644
index ed87aa3c..00000000
--- a/src/app/(protected)/locations/_components/location-delete-dialog.tsx
+++ /dev/null
@@ -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 (
-
- Delete this site?
-
-
- This will permanently remove {target?.name} from Sites. This cannot be undone.
-
-
-
-
- Cancel
-
-
- {isPending ? "Deleting..." : "Delete"}
-
-
-
- );
-}
diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx
index 840a06c0..a7332a76 100644
--- a/src/app/(protected)/locations/_components/locations-table.tsx
+++ b/src/app/(protected)/locations/_components/locations-table.tsx
@@ -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({
State
POC Name
POC Phone
-
- Actions
-
@@ -98,20 +90,6 @@ export function LocationsTable({
{row.phone || "—"}
-
-
- {
- event.stopPropagation();
- onDelete(row);
- }}
- >
-
-
-
-
))}
diff --git a/src/app/(protected)/locations/_components/site-delete-dialog.tsx b/src/app/(protected)/locations/_components/site-delete-dialog.tsx
new file mode 100644
index 00000000..5b7ba58b
--- /dev/null
+++ b/src/app/(protected)/locations/_components/site-delete-dialog.tsx
@@ -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 (
+ {
+ if (!deleteLocation.isPending) {
+ onClose();
+ }
+ }}
+ aria-labelledby="site-delete-title"
+ slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
+ >
+ Delete this site?
+
+ {isFetching && !isError && (
+
+ )}
+ {isError && {CHECK_FAILED_MESSAGE} }
+ {!countUnknown && (
+ {describeDeletion(siteCode, openCount)}
+ )}
+
+
+
+ Cancel
+
+ {isError && (
+ void refetch()}>
+ Try again
+
+ )}
+ {!countUnknown && openCount > 0 && (
+ navigate(openWorkOrdersHref)}>
+ View open work orders
+
+ )}
+ deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
+ >
+ {deleteLocation.isPending ? "Deleting..." : "Delete"}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx
index 3111bfa5..06e78919 100644
--- a/src/app/(protected)/locations/_components/site-detail-panel.tsx
+++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx
@@ -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 && (
+ setIsDeleteOpen(true)} className="mr-auto">
+ Delete
+
+ )}
setMode("edit")}>
Edit
+ setIsDeleteOpen(false)}
+ onDeleted={onClose}
+ />
>
)}
>
diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx
index 52308aad..6492b96b 100644
--- a/src/app/(protected)/locations/index.tsx
+++ b/src/app/(protected)/locations/index.tsx
@@ -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([]);
- const [deleteTarget, setDeleteTarget] = useState(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [selectedId, setSelectedId] = useState(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 (
setSelectedId(String(row.id))}
- onDelete={setDeleteTarget}
/>
setIsAddOpen(false)} />
- setDeleteTarget(null)}
- onConfirm={handleDeleteConfirm}
- />
);
}
diff --git a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts
new file mode 100644
index 00000000..eefc3bf1
--- /dev/null
+++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts
@@ -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 = {
+ pending: PENDING_HEADER_CELLS,
+ approved: APPROVED_HEADER_CELLS,
+ rejected: REJECTED_HEADER_CELLS,
+};
diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx
index 3f52d36b..9819d2cd 100644
--- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx
+++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx
@@ -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({
{row.requestedByVendorName || "—"}
- {tab === "pending" ? (
-
-
-
- ) : (
+ {tab === "pending" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {tab === "approved" && (
<>
{row.decidedByName || "—"}
{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}
+
+
+
>
)}
-
- {tab === "pending" ? (
-
- ) : (
-
- )}
+ {tab === "rejected" && }
+
+ );
+}
+
+function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
+ if (tab === "rejected") return ;
+ if (tab === "approved") {
+ return (
+
+ No approved uplifts yet
+
+ Uplifts you approve are archived here for reference.
+
+
+ );
+ }
+ return (
+
+ No uplifts waiting for approval.
+
+ );
+}
+
+// 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 (
+
+
+
);
}
-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 (
@@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
) : rows.length === 0 ? (
-
-
- {tab === "pending" ? (
-
- No uplifts waiting for approval.
-
- ) : (
-
- No approved uplifts yet
-
- Uplifts you approve are archived here for reference.
-
-
- )}
-
-
+
) : (
rows.map((row) => (
+ {row.decidedByName || "—"}
+ {row.decidedAt ? formatDateTime(row.decidedAt) : "—"}
+
+
+ {row.decisionNote || "—"}
+
+
+ >
+ );
+}
+
+export function RejectedEmptyState() {
+ return (
+
+
+ No rejected uplifts.
+
+ );
+}
diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx
index db538443..2aaf0005 100644
--- a/src/app/(protected)/uplifts/index.tsx
+++ b/src/app/(protected)/uplifts/index.tsx
@@ -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 = {
+ 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 (
+
+ );
+}
+
function UpliftApprovalsTabs({
tab,
- pendingCount,
+ counts,
onChange,
}: {
tab: UpliftApprovalTab;
- pendingCount: number;
+ counts: Record;
onChange: (_event: unknown, value: string) => void;
}) {
return (
-
- Pending
-
-
- }
- />
-
+ {UPLIFT_APPROVAL_TABS.map((value) => (
+
+ {TAB_LABELS[value]}
+
+
+ }
+ />
+ ))}
);
}
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 (
- 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("pending");
- const [pendingPage, setPendingPage] = useState(0);
- const [approvedPage, setApprovedPage] = useState(0);
+ const [pages, setPages] = useState>({
+ 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 (
-
+
-
+ {activeTotal != null && (
+ setPages((current) => ({ ...current, [tab]: page }))}
+ />
+ )}
{detailItem != null && (
+
+
+ {overlay}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
index 871468fc..c6c0bb61 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
@@ -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 (
<>
-
- {SAFETY_BULLETS.map((b, i) => (
-
- {b}
-
- ))}
-
+
-
- {proc.heading}
-
+ {proc.heading}
0 && (
<>
-
- PM Comments
-
+ PM Comments
-
}
>
-
-
- {isNotRequired && }
-
+
+
+
+
);
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx
new file mode 100644
index 00000000..01c87572
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+import { Text } from "@/components/ui/text";
+
+export function CompDocProcedureHeading({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx
new file mode 100644
index 00000000..ff1f99a0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx
@@ -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 (
+
+ {[...SAFETY_BULLETS, ...extra].map((b, i) => (
+
+ {b}
+
+ ))}
+
+ );
+}
diff --git a/src/config/menu.ts b/src/config/menu.ts
index 2c07d62a..d397d0d2 100644
--- a/src/config/menu.ts
+++ b/src/config/menu.ts
@@ -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",
diff --git a/src/domain/completion-templates/api/completion-templates-api.ts b/src/domain/completion-templates/api/completion-templates-api.ts
new file mode 100644
index 00000000..14e6d2d8
--- /dev/null
+++ b/src/domain/completion-templates/api/completion-templates-api.ts
@@ -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(schema: z.ZodType, data: unknown): T {
+ const result = schema.safeParse(handleApiResponse(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 => {
+ const term = search.trim();
+ const data = await apiGet(API_PATHS.workOrder.completionTemplates, {
+ searchParams: term === "" ? undefined : { search: term },
+ });
+ return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate);
+ },
+
+ create: async (input: CompletionTemplateInput): Promise => {
+ const data = await apiPost(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 => {
+ const data = await apiPut(
+ API_PATHS.workOrder.completionTemplateById(template.id),
+ toBody(input, template.legacy),
+ );
+ return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
+ },
+
+ delete: async (id: number): Promise => {
+ await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id));
+ },
+
+ linkedWorkOrders: async (id: number): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id));
+ return parse(linkedWorkOrdersDtoSchema, data);
+ },
+};
diff --git a/src/domain/completion-templates/constants/completion-template-constants.ts b/src/domain/completion-templates/constants/completion-template-constants.ts
new file mode 100644
index 00000000..b1f79c66
--- /dev/null
+++ b/src/domain/completion-templates/constants/completion-template-constants.ts
@@ -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"}`;
+}
diff --git a/src/domain/completion-templates/mappers/completion-template-mapper.ts b/src/domain/completion-templates/mappers/completion-template-mapper.ts
new file mode 100644
index 00000000..4b0188cb
--- /dev/null
+++ b/src/domain/completion-templates/mappers/completion-template-mapper.ts
@@ -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 !== ""),
+ };
+}
diff --git a/src/domain/completion-templates/schemas/completion-template-schema.ts b/src/domain/completion-templates/schemas/completion-template-schema.ts
new file mode 100644
index 00000000..7d6605ab
--- /dev/null
+++ b/src/domain/completion-templates/schemas/completion-template-schema.ts
@@ -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;
+
+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;
diff --git a/src/domain/completion-templates/types/completion-template.ts b/src/domain/completion-templates/types/completion-template.ts
new file mode 100644
index 00000000..ec1a28b5
--- /dev/null
+++ b/src/domain/completion-templates/types/completion-template.ts
@@ -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[];
+}
diff --git a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts
new file mode 100644
index 00000000..83010329
--- /dev/null
+++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts
@@ -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 {
+ 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"),
+ });
+}
diff --git a/src/domain/completion-templates/use-cases/use-completion-templates.ts b/src/domain/completion-templates/use-cases/use-completion-templates.ts
new file mode 100644
index 00000000..f3101016
--- /dev/null
+++ b/src/domain/completion-templates/use-cases/use-completion-templates.ts
@@ -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 {
+ 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 {
+ return useQuery({
+ queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0),
+ queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0),
+ enabled: templateId !== null,
+ staleTime: 0,
+ refetchOnMount: "always",
+ });
+}
diff --git a/src/domain/completion-templates/utils/description-lines.ts b/src/domain/completion-templates/utils/description-lines.ts
new file mode 100644
index 00000000..d9a2a047
--- /dev/null
+++ b/src/domain/completion-templates/utils/description-lines.ts
@@ -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);
+}
diff --git a/src/domain/completion-templates/utils/format-template-date.ts b/src/domain/completion-templates/utils/format-template-date.ts
new file mode 100644
index 00000000..042f061b
--- /dev/null
+++ b/src/domain/completion-templates/utils/format-template-date.ts
@@ -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" });
+}
diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts
index 3c122e5a..fc1a5f60 100644
--- a/src/domain/locations/api/locations-api.ts
+++ b/src/domain/locations/api/locations-api.ts
@@ -7,11 +7,13 @@ import {
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
+ mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
import type {
Location,
LocationSite,
SiteContactInfoInput,
+ SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@@ -84,4 +86,9 @@ export const locationsApi = {
delete: async (id: string | number): Promise => {
await apiDelete(`${API_PATHS.rest.locations}/${id}`);
},
+
+ getOpenWorkOrders: async (id: string | number): Promise => {
+ const data = await apiGet(`${API_PATHS.rest.locations}/${id}/open-work-orders`);
+ return mapSiteOpenWorkOrders(handleApiResponse(data));
+ },
};
diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts
index 99241eb0..dfb0a25f 100644
--- a/src/domain/locations/mappers/location-mapper.ts
+++ b/src/domain/locations/mappers/location-mapper.ts
@@ -3,6 +3,7 @@ import type {
LocationContact,
LocationListItem,
LocationSite,
+ SiteOpenWorkOrders,
} from "@/domain/locations/types/location";
function readString(record: Record, ...keys: string[]): string {
@@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record
return payload;
}
+
+export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders {
+ const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record;
+ 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,
+ };
+}
diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts
index b4ba7dc7..d9d30fbb 100644
--- a/src/domain/locations/types/location.ts
+++ b/src/domain/locations/types/location.ts
@@ -75,3 +75,9 @@ export interface SiteContactInfoInput {
contacts: LocationContact[];
notes: string;
}
+
+/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
+export interface SiteOpenWorkOrders {
+ count: number;
+ workOrderIds: number[];
+}
diff --git a/src/domain/locations/use-cases/use-site-open-work-orders.ts b/src/domain/locations/use-cases/use-site-open-work-orders.ts
new file mode 100644
index 00000000..f405c7ff
--- /dev/null
+++ b/src/domain/locations/use-cases/use-site-open-work-orders.ts
@@ -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 {
+ return useQuery({
+ queryKey: queryKeys.locations.openWorkOrders(siteId),
+ queryFn: () => locationsApi.getOpenWorkOrders(siteId),
+ enabled,
+ staleTime: 0,
+ });
+}
diff --git a/src/domain/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts
index 9ebcf4bd..256250ad 100644
--- a/src/domain/uplifts/use-cases/use-uplifts-queue.ts
+++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts
@@ -10,5 +10,8 @@ export function useUpliftsQueue(
queryKey: queryKeys.uplifts.queue(params as Record),
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 },
});
}
diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
index 2334b9b6..ebc73bee 100644
--- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts
+++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
@@ -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);
}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index eb8a0664..3368da22 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -81,6 +81,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) =>
@@ -88,6 +94,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,
diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts
index 035e91c0..e67c7bbf 100644
--- a/src/lib/auth/user-utils.ts
+++ b/src/lib/auth/user-utils.ts
@@ -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");
+}
diff --git a/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts
new file mode 100644
index 00000000..15e7909c
--- /dev/null
+++ b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts
@@ -0,0 +1,90 @@
+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 {
+ 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",
+ });
+}
+
+/** Answers the template endpoints the way the backend contract describes. */
+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.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);
+}
diff --git a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
new file mode 100644
index 00000000..30f1070c
--- /dev/null
+++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
@@ -0,0 +1,167 @@
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+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, 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(() => ({ userRoles: "Admin" }));
+
+vi.mock("@/api/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ...api,
+}));
+
+vi.mock("@/providers/auth-context", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useAuthContext: () => ({ user: { id: "u1", 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( , {
+ 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");
+}
+
+beforeEach(() => {
+ user = userEvent.setup({ delay: null });
+ auth.userRoles = "Admin";
+ serveTemplates(api, [BACKFLOW, SCRUBBER]);
+});
+
+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();
+ });
+
+ it("gives an Admin every template action", async () => {
+ renderPage();
+ const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
+
+ expect(screen.getByRole("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();
+ });
+
+ it("hides delete from a Scheduler, who may still add and edit", async () => {
+ auth.userRoles = "Scheduler";
+ renderPage();
+ const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
+
+ expect(screen.getByRole("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 add, edit and delete entirely from a user without those permissions", async () => {
+ auth.userRoles = "Dispatcher";
+ serveTemplates(api, [templateDto()]);
+ renderPage();
+ const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
+
+ 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();
+ });
+});
diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
new file mode 100644
index 00000000..87f0276b
--- /dev/null
+++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
@@ -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()),
+ ...api,
+}));
+vi.mock("react-toastify", () => ({ toast }));
+vi.mock("react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => navigate,
+}));
+vi.mock("@/providers/auth-context", async (importOriginal) => ({
+ ...(await importOriginal()),
+ 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( , {
+ route: "/completion-docs",
+ withAuth: false,
+ });
+}
+
+async function openNewTemplate() {
+ renderPage();
+ await screen.findByRole("row", { name: /Backflow Test Report/ });
+ await user.click(screen.getByRole("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());
+ });
+});
diff --git a/src/test/app/(protected)/locations/site-delete.test.tsx b/src/test/app/(protected)/locations/site-delete.test.tsx
new file mode 100644
index 00000000..10b2c1d1
--- /dev/null
+++ b/src/test/app/(protected)/locations/site-delete.test.tsx
@@ -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 {`${location.pathname}${location.search}`}
;
+}
+
+function renderAs(userRoles: string) {
+ const auth = { user: { userRoles } } as unknown as AuthContextValue;
+ return renderWithProviders(
+
+
+ } />
+ } />
+
+ ,
+ { withAuth: false, route: "/locations" },
+ );
+}
+
+async function openPanel(): Promise {
+ 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();
+ });
+});
diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
index 88499ad7..fd5cf055 100644
--- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx
+++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
@@ -278,17 +278,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( , { 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();
- });
});
diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx
index 3bbf9dc1..1816bb04 100644
--- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx
+++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx
@@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders( );
- 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( );
- 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( );
- 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( );
- 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."),
diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx
index 30a13a51..51c52285 100644
--- a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx
+++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx
@@ -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" }));
diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx
new file mode 100644
index 00000000..23323d0a
--- /dev/null
+++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx
@@ -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,
+ errorByStatus: {} as Record,
+ requestedStatuses: [] as Array,
+}));
+
+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()),
+ 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( );
+ 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( );
+
+ 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( );
+
+ 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();
+ });
+});
diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts
index 61184661..63ee8865 100644
--- a/src/test/config/menu.test.ts
+++ b/src/test/config/menu.test.ts
@@ -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);
});
diff --git a/src/test/domain/completion-templates/completion-template-mapper.test.ts b/src/test/domain/completion-templates/completion-template-mapper.test.ts
new file mode 100644
index 00000000..0b7209f7
--- /dev/null
+++ b/src/test/domain/completion-templates/completion-template-mapper.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions";
+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" }],
+ });
+ });
+});
+
+describe("completionTemplatePermissions", () => {
+ it.each([
+ ["Admin", { canCreate: true, canEdit: true, canDelete: true }],
+ ["Scheduler", { canCreate: true, canEdit: true, canDelete: false }],
+ ["Dispatcher", { canCreate: false, canEdit: false, canDelete: false }],
+ ["dispatcher, admin", { canCreate: true, canEdit: true, canDelete: true }],
+ ["Vendor", { canCreate: false, canEdit: false, canDelete: false }],
+ [undefined, { canCreate: false, canEdit: false, canDelete: false }],
+ ])("resolves %s to the role defaults", (roles, expected) => {
+ expect(completionTemplatePermissions(roles)).toEqual(expected);
+ });
+});
diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts
index 62ce35d1..cd8734ce 100644
--- a/src/test/domain/locations/mappers/location-mapper.test.ts
+++ b/src/test/domain/locations/mappers/location-mapper.test.ts
@@ -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: [],
+ });
+ });
+});
diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
new file mode 100644
index 00000000..f71eb8d4
--- /dev/null
+++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+ },
+ );
+
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
+ expect(toastMocks.error).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
index 7faab8b0..eea7afe8 100644
--- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
+++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
@@ -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(