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..2bcd70b0
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx
@@ -0,0 +1,97 @@
+import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
+import { Text } from "@/components/ui/text";
+import { TextField } from "@/components/ui/text-field";
+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..c2c62a62
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx
@@ -0,0 +1,49 @@
+import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
+import { TextField } from "@/components/ui/text-field";
+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..0a8bd5d7
--- /dev/null
+++ b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx
@@ -0,0 +1,159 @@
+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;
+ 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,
+ canEdit,
+ onView,
+ onEdit,
+}: CompletionTemplatesTableProps) {
+ return (
+
+
+
+ Document Title
+ Last Updated
+ Created By
+
+ Actions
+
+
+
+
+ {isLoading && }
+ {!isLoading && 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..924c418f
--- /dev/null
+++ b/src/app/(protected)/completion-docs/index.tsx
@@ -0,0 +1,98 @@
+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)/workorders/_components/comp-doc/comp-doc-document-frame.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx
new file mode 100644
index 00000000..30b02d01
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx
@@ -0,0 +1,55 @@
+import type { ReactNode } from "react";
+
+type CompDocDocumentFrameProps = {
+ children: ReactNode;
+ /** Greyed-out page, as for a document marked not required. */
+ dimmed?: boolean;
+ /** Rendered over the page, outside the dimmed layer. */
+ overlay?: ReactNode;
+};
+
+/** The printed page: white sheet between the top and bottom brand bands. */
+export function CompDocDocumentFrame({
+ children,
+ dimmed = false,
+ overlay,
+}: CompDocDocumentFrameProps) {
+ return (
+
+ );
+}
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..01e88186
--- /dev/null
+++ b/src/domain/completion-templates/constants/completion-template-constants.ts
@@ -0,0 +1,23 @@
+/** 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;
+
+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..2e9db1e4
--- /dev/null
+++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts
@@ -0,0 +1,81 @@
+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 {
+ return isHTTPError(error) && error.response.status === 422;
+}
+
+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/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index eb8a0664..c7719463 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) =>
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..54710f9e
--- /dev/null
+++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
@@ -0,0 +1,148 @@
+import { screen, 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 } }),
+}));
+
+function renderPage() {
+ return renderWithProviders( , {
+ route: "/completion-docs",
+ withAuth: false,
+ });
+}
+
+async function openTemplate(name: string) {
+ await userEvent.click(await screen.findByRole("row", { name: new RegExp(name) }));
+ return screen.findByRole("dialog");
+}
+
+beforeEach(() => {
+ auth.userRoles = "Admin";
+ serveTemplates(api, [BACKFLOW, SCRUBBER]);
+});
+
+describe("Completion Docs page", () => {
+ 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 userEvent.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 userEvent.clear(search);
+ await userEvent.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("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..09348782
--- /dev/null
+++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
@@ -0,0 +1,251 @@
+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" } }),
+}));
+
+function renderPage() {
+ return renderWithProviders( , {
+ route: "/completion-docs",
+ withAuth: false,
+ });
+}
+
+async function openNewTemplate() {
+ renderPage();
+ await screen.findByRole("row", { name: /Backflow Test Report/ });
+ await userEvent.click(screen.getByRole("button", { name: /Add Template/ }));
+ return screen.findByRole("dialog");
+}
+
+async function openTemplate(name: string) {
+ renderPage();
+ await userEvent.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);
+}
+
+async function addProcedure(dialog: HTMLElement, index: number, name: string) {
+ await userEvent.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
+ await userEvent.type(within(dialog).getByLabelText(`Procedure ${index} name`), name);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] });
+});
+
+describe("Add Template", () => {
+ it("blocks saving without a Document Title and says why", async () => {
+ const dialog = await openNewTemplate();
+
+ await userEvent.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 userEvent.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
+ expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull();
+ await userEvent.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit");
+ expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent(
+ "Mind the pit",
+ );
+
+ await userEvent.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 userEvent.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 userEvent.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 userEvent.type(
+ within(dialog).getByRole("textbox", { name: /Document Title/ }),
+ " PRV Check ",
+ );
+ await userEvent.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
+ await userEvent.type(within(dialog).getByLabelText("Extra safety note"), "Gloves");
+ await addProcedure(dialog, 1, "Open");
+ await userEvent.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b");
+ await userEvent.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
+ await addProcedure(dialog, 3, "Close");
+
+ await userEvent.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(
+ new HTTPError(
+ new Response(null, { status: 422 }),
+ new Request("http://api/workorders/completion-templates"),
+ {} as never,
+ ),
+ );
+ const dialog = await openNewTemplate();
+ await userEvent.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
+
+ await userEvent.click(within(dialog).getByRole("button", { name: /Add Template/ }));
+
+ expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+});
+
+describe("Edit template", () => {
+ it("Cancel discards unsaved changes and returns to the original read-only values", async () => {
+ const dialog = await openTemplate("Backflow Test Report");
+ await userEvent.click(within(dialog).getByRole("button", { name: /Edit/ }));
+ expect(within(dialog).getByText("Live preview")).toBeInTheDocument();
+ const title = within(dialog).getByRole("textbox", { name: /Document Title/ });
+ await userEvent.clear(title);
+ await userEvent.type(title, "Renamed");
+ await userEvent.click(within(dialog).getByRole("button", { name: "Remove procedure 1" }));
+
+ await userEvent.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 userEvent.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 userEvent.click(within(dialog).getByRole("button", { name: /Edit/ }));
+ await userEvent.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
+
+ await userEvent.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();
+ });
+});
+
+describe("Delete template", () => {
+ 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 userEvent.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 userEvent.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 userEvent.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 userEvent.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/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);
+ });
+});