From 50b97a96d0c115d92577318e3b506fa2bba08b6f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:08:21 -0300 Subject: [PATCH] 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. --- src/api/api-paths.ts | 3 + .../completion-template-add-link.tsx | 20 ++ .../completion-template-created-by.tsx | 42 +++ .../completion-template-detail.tsx | 87 ++++++ .../completion-template-dialog-chrome.tsx | 105 ++++++++ .../completion-template-dialog-heading.ts | 21 ++ .../completion-template-dialog.tsx | 102 +++++++ .../_components/completion-template-form.tsx | 64 +++++ .../completion-template-preview.tsx | 133 ++++++++++ .../completion-template-procedures-editor.tsx | 97 +++++++ .../completion-template-remove-button.tsx | 22 ++ .../completion-template-safety-note-field.tsx | 49 ++++ .../completion-template-sections.tsx | 75 ++++++ .../completion-templates-table.tsx | 159 +++++++++++ .../delete-completion-template-dialog.tsx | 98 +++++++ .../use-completion-template-dialog.ts | 109 ++++++++ .../use-completion-template-permissions.ts | 36 +++ src/app/(protected)/completion-docs/index.tsx | 98 +++++++ .../comp-doc/comp-doc-document-frame.tsx | 55 ++++ .../comp-doc/comp-doc-preview-procedure.tsx | 46 +--- .../_components/comp-doc/comp-doc-preview.tsx | 57 +--- .../comp-doc/comp-doc-procedure-heading.tsx | 19 ++ .../comp-doc/comp-doc-safety-bullets.tsx | 27 ++ src/config/menu.ts | 5 +- .../api/completion-templates-api.ts | 64 +++++ .../completion-template-constants.ts | 23 ++ .../mappers/completion-template-mapper.ts | 63 +++++ .../schemas/completion-template-schema.ts | 45 ++++ .../types/completion-template.ts | 38 +++ .../use-completion-template-mutations.ts | 81 ++++++ .../use-cases/use-completion-templates.ts | 30 +++ .../utils/description-lines.ts | 7 + .../utils/format-template-date.ts | 6 + src/infra/query-key/query-key.ts | 6 + .../completion-docs-fixtures.ts | 90 +++++++ .../completion-docs-page.test.tsx | 148 +++++++++++ .../completion-template-dialog.test.tsx | 251 ++++++++++++++++++ src/test/config/menu.test.ts | 9 + .../completion-template-mapper.test.ts | 67 +++++ 39 files changed, 2372 insertions(+), 85 deletions(-) create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-created-by.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-detail.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-dialog-chrome.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-dialog-heading.ts create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-dialog.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-form.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-preview.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-template-sections.tsx create mode 100644 src/app/(protected)/completion-docs/_components/completion-templates-table.tsx create mode 100644 src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx create mode 100644 src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts create mode 100644 src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts create mode 100644 src/app/(protected)/completion-docs/index.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx create mode 100644 src/domain/completion-templates/api/completion-templates-api.ts create mode 100644 src/domain/completion-templates/constants/completion-template-constants.ts create mode 100644 src/domain/completion-templates/mappers/completion-template-mapper.ts create mode 100644 src/domain/completion-templates/schemas/completion-template-schema.ts create mode 100644 src/domain/completion-templates/types/completion-template.ts create mode 100644 src/domain/completion-templates/use-cases/use-completion-template-mutations.ts create mode 100644 src/domain/completion-templates/use-cases/use-completion-templates.ts create mode 100644 src/domain/completion-templates/utils/description-lines.ts create mode 100644 src/domain/completion-templates/utils/format-template-date.ts create mode 100644 src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts create mode 100644 src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx create mode 100644 src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx create mode 100644 src/test/domain/completion-templates/completion-template-mapper.test.ts 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 ( + + ); +} 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) => ( +
  1. + + {procedure.name || `Procedure ${index + 1}`} + + {descriptionLines(procedure.description).map((line, lineIndex) => ( + + {line} + + ))} +
  2. + ))} +
+ ); +} + +/** Everything a template holds, read-only. */ +export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) { + return ( +
+ Completion Document + + + {template.name || "—"} + + + +
+ Safety Notes + +
+
+ Procedures + +
+
+ + + {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 ( +
+ + +
+ ); + } + return ( +
+ {permissions.canDelete && ( + + )} + {permissions.canEdit && ( + + )} +
+ ); +} 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 ( + + ); +} + +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 && ( + + )} + + + + ); +} 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 && ( + + )} + + } + /> + + {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 ( +
+
+
+
{children}
+
+
+ + {overlay} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx index 871468fc..c6c0bb61 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx @@ -1,7 +1,9 @@ import { Text } from "@/components/ui/text"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; +import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures"; +import { CompDocProcedureHeading } from "./comp-doc-procedure-heading"; import { CompDocProcedureList } from "./comp-doc-procedure-list"; +import { CompDocSafetyBullets } from "./comp-doc-safety-bullets"; type CompDocPreviewProcedureProps = { row: WorkOrderTableRow; @@ -13,34 +15,9 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP return ( <> -
    - {SAFETY_BULLETS.map((b, i) => ( -
  • - {b} -
  • - ))} -
+ - - {proc.heading} - + {proc.heading} 0 && ( <> - - PM Comments - + PM Comments -
} > -
-
-
- - - -
-
-
- - {isNotRequired && } -
+ + + +
); diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx new file mode 100644 index 00000000..01c87572 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; + +export function CompDocProcedureHeading({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx new file mode 100644 index 00000000..ff1f99a0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets.tsx @@ -0,0 +1,27 @@ +import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; + +type CompDocSafetyBulletsProps = { + /** Template-specific notes printed after the standard bullets. */ + extra?: readonly string[]; +}; + +export function CompDocSafetyBullets({ extra = [] }: CompDocSafetyBulletsProps) { + return ( +
    + {[...SAFETY_BULLETS, ...extra].map((b, i) => ( +
  • + {b} +
  • + ))} +
+ ); +} diff --git a/src/config/menu.ts b/src/config/menu.ts index 2c07d62a..d397d0d2 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -63,7 +63,10 @@ export const navGroups: NavGroup[] = [ }, { title: "DATA MANAGEMENT", - items: [{ label: "Services", icon: Wrench, path: "/settings/services" }], + items: [ + { label: "Services", icon: Wrench, path: "/settings/services" }, + { label: "Completion Docs", icon: FileText, path: "/completion-docs" }, + ], }, { title: "TEAM", diff --git a/src/domain/completion-templates/api/completion-templates-api.ts b/src/domain/completion-templates/api/completion-templates-api.ts new file mode 100644 index 00000000..14e6d2d8 --- /dev/null +++ b/src/domain/completion-templates/api/completion-templates-api.ts @@ -0,0 +1,64 @@ +import type { z } from "zod"; +import { apiDeleteNoContent, apiGet, apiPost, apiPut } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapCompletionTemplate } from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { + completionTemplateDtoSchema, + completionTemplateListDtoSchema, + linkedWorkOrdersDtoSchema, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { + CompletionTemplate, + CompletionTemplateInput, + CompletionTemplateLegacyFields, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; + +export const UNEXPECTED_TEMPLATE_RESPONSE = "The server sent an unexpected response."; + +function parse(schema: z.ZodType, data: unknown): T { + const result = schema.safeParse(handleApiResponse(data)); + if (!result.success) throw new Error(UNEXPECTED_TEMPLATE_RESPONSE); + return result.data; +} + +function toBody(input: CompletionTemplateInput, legacy?: CompletionTemplateLegacyFields) { + return { ...legacy, ...input }; +} + +export const completionTemplatesApi = { + list: async (search: string): Promise => { + const term = search.trim(); + const data = await apiGet(API_PATHS.workOrder.completionTemplates, { + searchParams: term === "" ? undefined : { search: term }, + }); + return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate); + }, + + create: async (input: CompletionTemplateInput): Promise => { + const data = await apiPost(API_PATHS.workOrder.completionTemplates, toBody(input)); + return mapCompletionTemplate(parse(completionTemplateDtoSchema, data)); + }, + + /** Replaces the whole procedure list and keeps the legacy fields the template already had. */ + update: async ( + template: CompletionTemplate, + input: CompletionTemplateInput, + ): Promise => { + const data = await apiPut( + API_PATHS.workOrder.completionTemplateById(template.id), + toBody(input, template.legacy), + ); + return mapCompletionTemplate(parse(completionTemplateDtoSchema, data)); + }, + + delete: async (id: number): Promise => { + await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id)); + }, + + linkedWorkOrders: async (id: number): Promise => { + const data = await apiGet(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id)); + return parse(linkedWorkOrdersDtoSchema, data); + }, +}; diff --git a/src/domain/completion-templates/constants/completion-template-constants.ts b/src/domain/completion-templates/constants/completion-template-constants.ts new file mode 100644 index 00000000..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); + }); +});