feat(completion-docs): manage completion document templates

Add the Completion Docs page under Data Management: a searchable template
table, a read-only detail panel, add and edit with a live document preview,
and delete behind a confirmation that counts the open work orders still
using the template and links to them. Add, edit and delete are hidden for
users whose role lacks the matching template permission.

The preview shares the work-order completion document's page frame,
header, safety bullets and procedure heading so the two stay identical.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:08:21 -03:00
parent 358ec3e890
commit 50b97a96d0
39 changed files with 2372 additions and 85 deletions

View file

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

View file

@ -0,0 +1,20 @@
import type { ReactNode } from "react";
export function CompletionTemplateAddLink({
onClick,
children,
}: {
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className="self-start"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)", textAlign: "left" }}
>
{children}
</button>
);
}

View file

@ -0,0 +1,42 @@
import { Text } from "@/components/ui/text";
function initials(name: string): string {
return name
.trim()
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
export function CompletionTemplateCreatedBy({ name }: { name: string | null }) {
if (name === null) {
return (
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
—
</Text>
);
}
return (
<span className="flex items-center gap-2">
<span
aria-hidden
className="flex shrink-0 items-center justify-center rounded-full"
style={{
width: 22,
height: 22,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-muted)",
color: "var(--color-primary)",
}}
>
{initials(name)}
</span>
<Text variant="caption" sx={{ fontSize: 13 }}>
{name}
</Text>
</span>
);
}

View file

@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
import {
AutoPopulatedFields,
FieldLabel,
SectionTitle,
StandardSafetyBullets,
} from "./completion-template-sections";
function ReadOnlyField({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<Text
variant="label"
tone="muted"
sx={{ fontSize: 12, fontWeight: 500, display: "block", marginBottom: "6px" }}
>
{label}
</Text>
{children}
</div>
);
}
function ProcedureList({ template }: { template: CompletionTemplate }) {
if (template.procedures.length === 0) {
return (
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
{COMPLETION_TEMPLATE_COPY.noProcedureDetail}
</Text>
);
}
return (
<ol className="m-0 list-none p-0">
{template.procedures.map((procedure, index) => (
<li key={index} style={{ marginBottom: 12 }}>
<Text variant="label" sx={{ fontSize: 13, fontWeight: 600, display: "block", mb: 0.5 }}>
{procedure.name || `Procedure ${index + 1}`}
</Text>
{descriptionLines(procedure.description).map((line, lineIndex) => (
<Text key={lineIndex} variant="body" sx={{ fontSize: 13, lineHeight: 1.5, my: 0.5 }}>
{line}
</Text>
))}
</li>
))}
</ol>
);
}
/** Everything a template holds, read-only. */
export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) {
return (
<div className="grid gap-3.5">
<SectionTitle>Completion Document</SectionTitle>
<ReadOnlyField label="Document Title">
<Text variant="body" sx={{ fontSize: 13 }}>
{template.name || "—"}
</Text>
</ReadOnlyField>
<AutoPopulatedFields />
<div>
<FieldLabel>Safety Notes</FieldLabel>
<StandardSafetyBullets extra={template.extraSafetyNote} />
</div>
<div>
<FieldLabel hint="(optional)">Procedures</FieldLabel>
<ProcedureList template={template} />
</div>
<div className="grid grid-cols-2 gap-3">
<ReadOnlyField label="Last Updated">
<Text variant="body" sx={{ fontSize: 13 }}>
{formatTemplateDate(template.lastUpdatedAt)}
</Text>
</ReadOnlyField>
<ReadOnlyField label="Created By">
<CompletionTemplateCreatedBy name={template.createdByName} />
</ReadOnlyField>
</div>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { FileText, Pencil, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { IconCloseButton } from "@/components/ui/icon-close-button";
import { Text } from "@/components/ui/text";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
export function CompletionTemplateDialogHeader({
title,
subtitle,
onClose,
}: {
title: string;
subtitle: string;
onClose: () => void;
}) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<Text
as="div"
variant="heading"
className="truncate"
sx={{ fontSize: 16, fontWeight: 700, lineHeight: 1.1 }}
>
{title}
</Text>
<Text as="div" variant="caption" tone="muted" className="truncate" sx={{ fontSize: 13 }}>
{subtitle}
</Text>
</div>
</div>
<IconCloseButton onClick={onClose} />
</div>
);
}
type FooterProps = {
mode: CompletionTemplateDialogMode;
permissions: CompletionTemplatePermissions;
isSaving: boolean;
onCancel: () => void;
onSubmit: () => void;
onEdit: () => void;
onDelete: () => void;
};
export function CompletionTemplateDialogFooter({
mode,
permissions,
isSaving,
onCancel,
onSubmit,
onEdit,
onDelete,
}: FooterProps) {
const footerStyle = {
padding: "12px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
flexShrink: 0,
};
if (mode !== "view") {
return (
<div className="flex items-center justify-end gap-3" style={footerStyle}>
<DialogCancelButton onClick={onCancel} disabled={isSaving} />
<Button onClick={onSubmit} disabled={isSaving} className="gap-1.5">
{mode === "create" ? <Plus size={13} /> : <Save size={13} />}
{mode === "create"
? COMPLETION_TEMPLATE_COPY.addTemplate
: COMPLETION_TEMPLATE_COPY.saveChanges}
</Button>
</div>
);
}
return (
<div
className={`flex items-center gap-3 ${permissions.canDelete ? "justify-between" : "justify-end"}`}
style={footerStyle}
>
{permissions.canDelete && (
<Button variant="ghost" onClick={onDelete} className="gap-1.5 text-destructive">
<Trash2 size={13} /> Delete
</Button>
)}
{permissions.canEdit && (
<Button onClick={onEdit} className="gap-1.5">
<Pencil size={13} /> Edit
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,21 @@
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
export function dialogHeading(
template: CompletionTemplate | null,
mode: CompletionTemplateDialogMode,
): { title: string; subtitle: string } {
if (template === null) {
return {
title: "New Completion Document Template",
subtitle: "Completion Document Template",
};
}
if (mode === "edit") return { title: template.name, subtitle: "Editing template" };
const createdBy = template.createdByName ?? "—";
return {
title: template.name,
subtitle: `Created by ${createdBy} · Updated ${formatTemplateDate(template.lastUpdatedAt)}`,
};
}

View file

@ -0,0 +1,102 @@
import { Dialog, DialogTitle } from "@mui/material";
import { useState } from "react";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import { CompletionTemplateDetail } from "./completion-template-detail";
import {
CompletionTemplateDialogFooter,
CompletionTemplateDialogHeader,
} from "./completion-template-dialog-chrome";
import { dialogHeading } from "./completion-template-dialog-heading";
import { CompletionTemplateForm } from "./completion-template-form";
import { CompletionTemplatePreview } from "./completion-template-preview";
import { DeleteCompletionTemplateDialog } from "./delete-completion-template-dialog";
import {
useCompletionTemplateDialog,
type CompletionTemplateDialogMode,
} from "./use-completion-template-dialog";
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
type CompletionTemplateDialogProps = {
/** `null` adds a new template. */
template: CompletionTemplate | null;
initialMode: CompletionTemplateDialogMode;
permissions: CompletionTemplatePermissions;
onClose: () => void;
onSaved: (template: CompletionTemplate) => void;
};
/** View, add or edit one template beside its document preview. */
export function CompletionTemplateDialog({
template,
initialMode,
permissions,
onClose,
onSaved,
}: CompletionTemplateDialogProps) {
const dialog = useCompletionTemplateDialog({ template, initialMode, onClose, onSaved });
const [confirmingDelete, setConfirmingDelete] = useState(false);
const heading = dialogHeading(template, dialog.mode);
return (
<Dialog
open
onClose={onClose}
maxWidth={false}
aria-labelledby="completion-template-dialog-title"
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1040px, calc(100vw - 32px))",
maxWidth: "none",
background: "var(--color-bg-muted)",
},
},
}}
>
<DialogTitle id="completion-template-dialog-title" className="sr-only">
{heading.title}
</DialogTitle>
<CompletionTemplateDialogHeader
title={heading.title}
subtitle={heading.subtitle}
onClose={onClose}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompletionTemplatePreview values={dialog.previewValues} isEditing={dialog.isEditing} />
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
{dialog.isEditing ? (
<CompletionTemplateForm form={dialog.form} />
) : (
template !== null && <CompletionTemplateDetail template={template} />
)}
</div>
</div>
</div>
<CompletionTemplateDialogFooter
mode={dialog.mode}
permissions={permissions}
isSaving={dialog.isSaving}
onCancel={dialog.cancel}
onSubmit={() => void dialog.submit()}
onEdit={dialog.startEditing}
onDelete={() => setConfirmingDelete(true)}
/>
{template !== null && permissions.canDelete && (
<DeleteCompletionTemplateDialog
template={template}
open={confirmingDelete}
onClose={() => setConfirmingDelete(false)}
onDeleted={onClose}
/>
)}
</Dialog>
);
}

View file

@ -0,0 +1,64 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FormField } from "@/components/ui/form-field";
import { TextField } from "@/components/ui/text-field";
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
import { CompletionTemplateProceduresEditor } from "./completion-template-procedures-editor";
import { CompletionTemplateSafetyNoteField } from "./completion-template-safety-note-field";
import {
AutoPopulatedFields,
FieldLabel,
SectionTitle,
StandardSafetyBullets,
} from "./completion-template-sections";
type CompletionTemplateFormProps = {
form: UseFormReturn<CompletionTemplateFormValues>;
};
function TitleField({ form }: CompletionTemplateFormProps) {
return (
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<FormField
label="Document Title"
htmlFor="completion-template-title"
required
error={fieldState.error !== undefined}
helperText={fieldState.error?.message}
>
<TextField
id="completion-template-title"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Completion document title"
error={fieldState.error !== undefined}
/>
</FormField>
)}
/>
);
}
/** The editable template: title, fixed references, the optional note and procedures. */
export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) {
return (
<div className="grid gap-3.5">
<SectionTitle>Completion Document</SectionTitle>
<TitleField form={form} />
<AutoPopulatedFields />
<div className="grid gap-1">
<FieldLabel>Safety Notes</FieldLabel>
<StandardSafetyBullets />
<CompletionTemplateSafetyNoteField form={form} />
</div>
<div>
<FieldLabel hint="(optional)">Procedures</FieldLabel>
<CompletionTemplateProceduresEditor form={form} />
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
import { FileText } from "lucide-react";
import { Text } from "@/components/ui/text";
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame";
import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header";
import { CompDocProcedureHeading } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading";
import { CompDocSafetyBullets } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets";
import {
AUTO_POPULATED_FIELDS,
COMPLETION_TEMPLATE_COPY,
} from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
export interface CompletionTemplatePreviewValues {
name: string;
extraSafetyNote: string | null;
procedures: CompletionTemplateProcedure[];
}
type CompletionTemplatePreviewProps = {
values: CompletionTemplatePreviewValues;
isEditing: boolean;
};
function PreviewPill({ isEditing }: { isEditing: boolean }) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
<FileText size={13} />
{isEditing ? "Live preview" : "Document preview"}
</span>
</div>
);
}
function PreviewTitle({ name }: { name: string }) {
const title = name.trim();
return (
<>
<Text
as="h2"
variant="title"
family="body"
sx={{
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: title ? "#161E38" : "#9CA3AF",
margin: "16px 0 4px",
}}
>
{title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument}
</Text>
<Text variant="body" sx={{ fontSize: 12, color: "#58595B", margin: "2px 0 12px" }}>
{AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix}
</Text>
</>
);
}
function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
if (procedures.length === 0) {
return (
<Text
variant="body"
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
>
{COMPLETION_TEMPLATE_COPY.noProcedurePreview}
</Text>
);
}
return (
<>
{procedures.map((procedure, index) => (
<section key={index} aria-label={`Preview procedure ${index + 1}`}>
<CompDocProcedureHeading>
{procedure.name.trim() || `Procedure ${index + 1}`}
</CompDocProcedureHeading>
{descriptionLines(procedure.description).map((line, lineIndex) => (
<Text
key={lineIndex}
variant="body"
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
>
{line}
</Text>
))}
</section>
))}
</>
);
}
/** The template rendered on the same page, header and safety bullets as a work order's document. */
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
const note = values.extraSafetyNote?.trim() ?? "";
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<PreviewPill isEditing={isEditing} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
data-testid="completion-template-preview"
>
<CompDocDocumentFrame>
<CompDocPreviewHeader />
<PreviewTitle name={values.name} />
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
<PreviewProcedures procedures={values.procedures} />
</CompDocDocumentFrame>
</div>
</div>
);
}

View file

@ -0,0 +1,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<CompletionTemplateFormValues>;
};
const DESCRIPTION_PLACEHOLDER =
"Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well.";
function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) {
return (
<div className="grid gap-2.5">
<Controller
control={form.control}
name={`procedures.${index}.name`}
render={({ field }) => (
<TextField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Procedure name (e.g. Backflow Procedure)"
slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} name` } }}
/>
)}
/>
<Controller
control={form.control}
name={`procedures.${index}.description`}
render={({ field }) => (
<TextField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
multiline
minRows={5}
maxRows={8}
placeholder={DESCRIPTION_PLACEHOLDER}
slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} description` } }}
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
/>
)}
/>
</div>
);
}
/** Procedures print in the order they are listed here. */
export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) {
const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" });
return (
<div className="grid gap-3">
{fields.map((field, index) => (
<fieldset
key={field.id}
aria-label={`Procedure ${index + 1}`}
className="m-0 min-w-0"
style={{
padding: 12,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<Text
variant="caption"
tone="muted"
sx={{
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Procedure {index + 1}
</Text>
<CompletionTemplateRemoveButton
label={`Remove procedure ${index + 1}`}
onClick={() => remove(index)}
/>
</div>
<ProcedureFields form={form} index={index} />
</fieldset>
))}
<CompletionTemplateAddLink onClick={() => append({ name: "", description: "" })}>
+ Add procedure
</CompletionTemplateAddLink>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { X } from "lucide-react";
export function CompletionTemplateRemoveButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
>
<X size={14} />
</button>
);
}

View file

@ -0,0 +1,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<CompletionTemplateFormValues>;
};
/** At most one template-specific note, printed after the standard bullets. */
export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) {
const note = useWatch({ control: form.control, name: "extraSafetyNote" });
const setNote = (value: string | null) =>
form.setValue("extraSafetyNote", value, { shouldDirty: true });
if (note === null) {
return (
<CompletionTemplateAddLink onClick={() => setNote("")}>
+ Add safety note
</CompletionTemplateAddLink>
);
}
return (
<div className="flex items-start gap-2">
<Controller
control={form.control}
name="extraSafetyNote"
render={({ field }) => (
<TextField
multiline
minRows={3}
maxRows={8}
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="Type safety note…"
slotProps={{ htmlInput: { "aria-label": "Extra safety note" } }}
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
/>
)}
/>
<div style={{ marginTop: 6 }}>
<CompletionTemplateRemoveButton label="Remove safety note" onClick={() => setNote(null)} />
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants";
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
export function SectionTitle({ children }: { children: ReactNode }) {
return (
<Text as="div" variant="heading" family="body" sx={{ fontSize: 16, fontWeight: 600 }}>
{children}
</Text>
);
}
export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) {
return (
<div className="flex items-baseline gap-1.5" style={{ marginBottom: 4 }}>
<Text
variant="caption"
tone="muted"
sx={{ fontSize: 11, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.06em" }}
>
{children}
</Text>
{hint !== undefined && (
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
{hint}
</Text>
)}
</div>
);
}
export function AutoPopulatedFields() {
return (
<div>
<FieldLabel>Auto-populated fields</FieldLabel>
<ul className="m-0 flex list-none flex-wrap items-center gap-1.5 p-0">
{AUTO_POPULATED_FIELDS.map((field) => (
<li
key={field}
className="inline-flex items-center"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-muted)",
fontSize: 12,
padding: "2px 8px",
borderRadius: 4,
}}
>
{field}
</li>
))}
</ul>
</div>
);
}
/** The standard bullets every generated document prints; never editable per template. */
export function StandardSafetyBullets({ extra }: { extra?: string | null }) {
return (
<ul aria-label="Safety notes" style={{ margin: "0 0 8px", paddingLeft: 18 }}>
{SAFETY_BULLETS.map((bullet) => (
<li
key={bullet}
style={{ fontSize: 12.5, color: "var(--color-text-muted)", margin: "2px 0" }}
>
{bullet}
</li>
))}
{Boolean(extra) && (
<li style={{ fontSize: 12.5, margin: "2px 0", whiteSpace: "pre-wrap" }}>{extra}</li>
)}
</ul>
);
}

View file

@ -0,0 +1,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 (
<button
type="button"
title={label}
aria-label={label}
onClick={(event: MouseEvent) => {
event.stopPropagation();
onClick();
}}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
>
<Icon size={15} />
</button>
);
}
function EmptyRow() {
return (
<TableRow>
<TableCell colSpan={4} sx={{ padding: 0, borderBottom: 0 }}>
<div
className="flex flex-col items-center justify-center gap-2 text-center"
style={{ padding: "64px 16px" }}
>
<Search size={22} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="body" sx={{ fontSize: 14, fontWeight: 600 }}>
{COMPLETION_TEMPLATE_COPY.emptySearchTitle}
</Text>
<Text variant="description" tone="muted" sx={{ fontSize: 12.5 }}>
{COMPLETION_TEMPLATE_COPY.emptySearchHint}
</Text>
</div>
</TableCell>
</TableRow>
);
}
function TemplateRow({
template,
canEdit,
onView,
onEdit,
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading"> & {
template: CompletionTemplate;
}) {
return (
<TableRow hover onClick={() => onView(template)} sx={{ cursor: "pointer", height: 52 }}>
<TableCell sx={BODY_CELL_SX}>
<span className="inline-flex items-center gap-2.5">
<span
aria-hidden
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 28,
height: 28,
background: "var(--color-bg-muted)",
color: "var(--color-primary)",
}}
>
<FileText size={14} />
</span>
{template.name}
</span>
</TableCell>
<TableCell sx={{ ...BODY_CELL_SX, color: "text.secondary" }}>
{formatTemplateDate(template.lastUpdatedAt)}
</TableCell>
<TableCell sx={BODY_CELL_SX}>
<CompletionTemplateCreatedBy name={template.createdByName} />
</TableCell>
<TableCell sx={BODY_CELL_SX}>
<span className="flex items-center gap-1">
<RowAction label="View details" icon={Eye} onClick={() => onView(template)} />
{canEdit && <RowAction label="Edit" icon={Pencil} onClick={() => onEdit(template)} />}
</span>
</TableCell>
</TableRow>
);
}
export function CompletionTemplatesTable({
templates,
isLoading,
canEdit,
onView,
onEdit,
}: CompletionTemplatesTableProps) {
return (
<Table
stickyHeader
size="small"
aria-label="Completion document templates"
sx={{ minWidth: 700 }}
>
<TableHead>
<TableRow>
<TableCell sx={{ ...HEAD_CELL_SX, width: 360 }}>Document Title</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 140 }}>Last Updated</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 180 }}>Created By</TableCell>
<TableCell sx={{ ...HEAD_CELL_SX, width: 90 }}>
<span className="sr-only">Actions</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoadingRow colSpan={4} />}
{!isLoading && templates.length === 0 && <EmptyRow />}
{templates.map((template) => (
<TemplateRow
key={template.id}
template={template}
canEdit={canEdit}
onView={onView}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}

View file

@ -0,0 +1,98 @@
import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { Text } from "@/components/ui/text";
import type {
CompletionTemplate,
LinkedWorkOrders,
} from "@/domain/completion-templates/types/completion-template";
import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
type DeleteCompletionTemplateDialogProps = {
template: CompletionTemplate;
open: boolean;
onClose: () => void;
onDeleted: () => void;
};
function deleteMessage(name: string, linked: LinkedWorkOrders): string {
if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`;
const plural = linked.count === 1 ? "" : "s";
return (
`This will permanently remove "${name}". It's still linked to ${linked.count} open work ` +
`order${plural} through its Service — deleting it won't change that Service, but new ` +
"documents generated for those work orders will fall back to a generic procedure until " +
"reassigned. This cannot be undone."
);
}
function LinkedWorkOrdersMessage({
name,
linked,
}: {
name: string;
linked: ReturnType<typeof useLinkedWorkOrders>;
}) {
if (linked.isPending) {
return (
<div className="flex items-center gap-2" role="status">
<CircularProgress size={16} />
<Text variant="description" tone="muted">
Checking linked work orders…
</Text>
</div>
);
}
if (linked.isError) {
return (
<Text variant="error">
Couldn't check which work orders use this template. Close this and try again.
</Text>
);
}
return <Text variant="description">{deleteMessage(name, linked.data)}</Text>;
}
/** Deleting always asks first, and names the open work orders that still depend on the template. */
export function DeleteCompletionTemplateDialog({
template,
open,
onClose,
onDeleted,
}: DeleteCompletionTemplateDialogProps) {
const navigate = useNavigate();
const linked = useLinkedWorkOrders(open ? template.id : null);
const remove = useDeleteCompletionTemplate();
const linkedIds = linked.data?.workOrderIds ?? [];
function confirmDelete() {
remove.mutate(template.id, { onSuccess: onDeleted });
}
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Delete this template?</DialogTitle>
<DialogContent>
<LinkedWorkOrdersMessage name={template.name} linked={linked} />
</DialogContent>
<DialogActions>
<DialogCancelButton onClick={onClose} />
{linkedIds.length > 0 && (
<Button variant="outline" onClick={() => void navigate(buildWorkOrderIdsHref(linkedIds))}>
View linked work orders
</Button>
)}
<Button
variant="destructive"
onClick={confirmDelete}
disabled={!linked.isSuccess || remove.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,109 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
import {
toCompletionTemplateFormValues,
toCompletionTemplateInput,
} from "@/domain/completion-templates/mappers/completion-template-mapper";
import {
completionTemplateFormSchema,
type CompletionTemplateFormValues,
} from "@/domain/completion-templates/schemas/completion-template-schema";
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
import {
isTitleRequiredError,
useCreateCompletionTemplate,
useUpdateCompletionTemplate,
} from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
import type { CompletionTemplatePreviewValues } from "./completion-template-preview";
export type CompletionTemplateDialogMode = "create" | "view" | "edit";
type UseCompletionTemplateDialogOptions = {
template: CompletionTemplate | null;
initialMode: CompletionTemplateDialogMode;
onClose: () => void;
onSaved: (template: CompletionTemplate) => void;
};
function usePreviewValues(
control: ReturnType<typeof useForm<CompletionTemplateFormValues>>["control"],
): CompletionTemplatePreviewValues {
const watched = useWatch({ control });
return {
name: watched.name ?? "",
extraSafetyNote: watched.extraSafetyNote ?? null,
procedures: (watched.procedures ?? []).map((procedure) => ({
name: procedure?.name ?? "",
description: procedure?.description ?? "",
})),
};
}
export function useCompletionTemplateDialog({
template,
initialMode,
onClose,
onSaved,
}: UseCompletionTemplateDialogOptions) {
const [mode, setMode] = useState<CompletionTemplateDialogMode>(
template === null ? "create" : initialMode,
);
const form = useForm<CompletionTemplateFormValues>({
resolver: zodResolver(completionTemplateFormSchema),
defaultValues: toCompletionTemplateFormValues(template),
});
const previewValues = usePreviewValues(form.control);
const create = useCreateCompletionTemplate();
const update = useUpdateCompletionTemplate();
function showServerTitleError(error: Error) {
if (!isTitleRequiredError(error)) return;
form.setError(
"name",
{ message: COMPLETION_TEMPLATE_COPY.titleRequired },
{ shouldFocus: true },
);
}
const submit = form.handleSubmit((values) => {
const input = toCompletionTemplateInput(values);
if (template === null) {
create.mutate(input, { onSuccess: onClose, onError: showServerTitleError });
return;
}
update.mutate(
{ template, input },
{
onSuccess: (saved) => {
form.reset(toCompletionTemplateFormValues(saved));
setMode("view");
onSaved(saved);
},
onError: showServerTitleError,
},
);
});
/** Discards unsaved edits: back to the template as it was, or closed when creating. */
function cancel() {
if (template === null) {
onClose();
return;
}
form.reset(toCompletionTemplateFormValues(template));
setMode("view");
}
return {
mode,
isEditing: mode !== "view",
form,
previewValues,
submit,
cancel,
startEditing: () => setMode("edit"),
isSaving: create.isPending || update.isPending,
};
}

View file

@ -0,0 +1,36 @@
import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
import { useAuthContext } from "@/providers/auth-context";
const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"];
export interface CompletionTemplatePermissions {
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
}
/**
* The signed-in user's session carries roles, not per-user permission overrides, so each key
* resolves to the role default the Team Members screen and the server policy start from.
*/
export function completionTemplatePermissions(
userRoles: string | null | undefined,
): CompletionTemplatePermissions {
const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)]));
return {
canCreate: granted.has("createCompletionDocTemplates"),
canEdit: granted.has("editCompletionDocTemplates"),
canDelete: granted.has("deleteCompletionDocTemplates"),
};
}
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const { user } = useAuthContext();
return completionTemplatePermissions(user?.userRoles);
}

View file

@ -0,0 +1,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<OpenDialog | null>(null);
const templates = filtered.data ?? [];
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
title={COMPLETION_TEMPLATE_COPY.pageTitle}
subtitle={templateCountLabel(all.data?.length ?? 0)}
actions={
<div className="flex items-center gap-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={COMPLETION_TEMPLATE_COPY.searchPlaceholder}
aria-label="Search document title"
className="w-56"
/>
{permissions.canCreate && (
<Button
onClick={() => setDialog({ template: null, mode: "create" })}
className="gap-1.5"
>
<Plus size={13} /> {COMPLETION_TEMPLATE_COPY.addTemplate}
</Button>
)}
</div>
}
/>
{filtered.error !== null && (
<QueryErrorPanel
className="mt-3 shrink-0"
compact
message={filtered.error.message || "Failed to load templates."}
onRetry={() => void filtered.refetch()}
/>
)}
<div
className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-border"
style={{ background: "var(--card)" }}
>
<CompletionTemplatesTable
templates={templates}
isLoading={filtered.isPending}
canEdit={permissions.canEdit}
onView={(template) => setDialog({ template, mode: "view" })}
onEdit={(template) => setDialog({ template, mode: "edit" })}
/>
</div>
<div className="shrink-0 px-4 py-2">
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
{templateCountLabel(templates.length)}
</Text>
</div>
{dialog !== null && (
<CompletionTemplateDialog
key={dialog.template?.id ?? "new"}
template={dialog.template}
initialMode={dialog.mode}
permissions={permissions}
onClose={() => setDialog(null)}
onSaved={(template) => setDialog({ template, mode: "view" })}
/>
)}
</Box>
);
}

View file

@ -0,0 +1,55 @@
import type { ReactNode } from "react";
type CompDocDocumentFrameProps = {
children: ReactNode;
/** Greyed-out page, as for a document marked not required. */
dimmed?: boolean;
/** Rendered over the page, outside the dimmed layer. */
overlay?: ReactNode;
};
/** The printed page: white sheet between the top and bottom brand bands. */
export function CompDocDocumentFrame({
children,
dimmed = false,
overlay,
}: CompDocDocumentFrameProps) {
return (
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>{children}</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{overlay}
</div>
);
}

View file

@ -1,7 +1,9 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
@ -13,34 +15,9 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
return (
<>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<CompDocSafetyBullets />
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
<Text
variant="body"
sx={{
@ -56,18 +33,7 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{

View file

@ -1,5 +1,6 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDocumentFrame } from "./comp-doc-document-frame";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
@ -38,51 +39,19 @@ export function CompDocPreview({
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{isNotRequired && <CompDocNotRequiredStamp />}
</div>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</CompDocDocumentFrame>
</div>
</div>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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"}`;
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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<void, Error, number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => completionTemplatesApi.delete(id),
onSuccess: () => {
invalidateTemplates(queryClient);
toast.success(COMPLETION_TEMPLATE_COPY.deleted);
},
onError: (error: Error) => toastFailure(error, "Failed to delete template"),
});
}

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>) =>

View file

@ -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> = {}): 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);
}

View file

@ -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<typeof import("@/api/api")>()),
...api,
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "u1", userRoles: auth.userRoles } }),
}));
function renderPage() {
return renderWithProviders(<CompletionDocsPage />, {
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();
});
});

View file

@ -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<typeof import("@/api/api")>()),
...api,
}));
vi.mock("react-toastify", () => ({ toast }));
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => navigate,
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }),
}));
function renderPage() {
return renderWithProviders(<CompletionDocsPage />, {
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());
});
});

View file

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

View file

@ -0,0 +1,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);
});
});