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); + }); +});