diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 61410e8e..2833ec24 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index c73fc9e3..84e81461 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index 0c695940..80b51f39 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 0b081cba..8ff483ba 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 18302e36..50f3c6df 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index a2f46763..ede6fff5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 1ba120c7..19477e67 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index 869457df..112e0ec4 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 6a2eb6e5..c2ae4657 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 53582910..61338ef2 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index c9f0986a..d1aa8df6 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index 8382b445..ca02832a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index 5f3e96ae..71bbcf52 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index 534e8552..1a45825b 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 117f2d30..65075a30 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index b6349d96..0b402d6e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index cae7003a..c436a8d8 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 1d727318..983bd92f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index b5da6165..8b4009bf 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -33,6 +33,9 @@ export const API_PATHS = { pocPatch: (id: string | number) => `workorders/${id}/poc`, boardDetail: (id: string | number) => `workorders/${id}/detail`, completionTemplates: "workorders/completion-templates", + completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`, + completionTemplateLinkedWorkOrders: (id: string | number) => + `workorders/completion-templates/${id}/linked-work-orders`, comments: (id: string | number) => `workorders/${id}/comments`, commentItem: (workOrderId: string | number, commentId: string | number) => `workorders/${workOrderId}/comments/${commentId}`, diff --git a/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx new file mode 100644 index 00000000..4129b919 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-add-link.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export function CompletionTemplateAddLink({ + onClick, + children, +}: { + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} 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..d35a2ec4 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx @@ -0,0 +1,103 @@ +import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; +import { Text } from "@/components/ui/text"; +import { TextField } from "@/components/ui/text-field"; +import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; +import { CompletionTemplateAddLink } from "./completion-template-add-link"; +import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; + +type ProceduresEditorProps = { + form: UseFormReturn; +}; + +const DESCRIPTION_PLACEHOLDER = + "Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well."; + +function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) { + return ( +
+ ( + + )} + /> + ( + + )} + /> +
+ ); +} + +/** Procedures print in the order they are listed here. */ +export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) { + const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" }); + + return ( +
+ {fields.map((field, index) => ( +
+
+ + Procedure {index + 1} + + remove(index)} + /> +
+ +
+ ))} + append({ name: "", description: "" })}> + + Add procedure + +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx new file mode 100644 index 00000000..f1d0b1b5 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-remove-button.tsx @@ -0,0 +1,22 @@ +import { X } from "lucide-react"; + +export function CompletionTemplateRemoveButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx new file mode 100644 index 00000000..fad8cac5 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx @@ -0,0 +1,55 @@ +import { Controller, useWatch, type UseFormReturn } from "react-hook-form"; +import { TextField } from "@/components/ui/text-field"; +import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; +import { CompletionTemplateAddLink } from "./completion-template-add-link"; +import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; + +type SafetyNoteFieldProps = { + form: UseFormReturn; +}; + +/** At most one template-specific note, printed after the standard bullets. */ +export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) { + const note = useWatch({ control: form.control, name: "extraSafetyNote" }); + const setNote = (value: string | null) => + form.setValue("extraSafetyNote", value, { shouldDirty: true }); + + if (note === null) { + return ( + setNote("")}> + + Add safety note + + ); + } + return ( +
+ ( + + )} + /> +
+ setNote(null)} /> +
+
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx new file mode 100644 index 00000000..6dd48810 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-template-sections.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { Text } from "@/components/ui/text"; +import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants"; +import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; + +export function SectionTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) { + return ( +
+ + {children} + + {hint !== undefined && ( + + {hint} + + )} +
+ ); +} + +export function AutoPopulatedFields() { + return ( +
+ Auto-populated fields +
    + {AUTO_POPULATED_FIELDS.map((field) => ( +
  • + {field} +
  • + ))} +
+
+ ); +} + +/** The standard bullets every generated document prints; never editable per template. */ +export function StandardSafetyBullets({ extra }: { extra?: string | null }) { + return ( +
    + {SAFETY_BULLETS.map((bullet) => ( +
  • + {bullet} +
  • + ))} + {Boolean(extra) && ( +
  • {extra}
  • + )} +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx new file mode 100644 index 00000000..5756ad64 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx @@ -0,0 +1,162 @@ +import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material"; +import { Eye, FileText, Pencil, Search } from "lucide-react"; +import type { MouseEvent } from "react"; +import { TableLoadingRow } from "@/components/ui/table-state-rows"; +import { Text } from "@/components/ui/text"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date"; +import { CompletionTemplateCreatedBy } from "./completion-template-created-by"; + +const HEAD_CELL_SX = { + fontSize: 10.5, + fontWeight: 700, + letterSpacing: "0.06em", + textTransform: "uppercase", + background: "var(--color-bg-muted)", + padding: "11px 14px", + whiteSpace: "nowrap", +} as const; + +const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const; + +type CompletionTemplatesTableProps = { + templates: CompletionTemplate[]; + isLoading: boolean; + /** The load failed: the page shows the error, so there is no search result to explain. */ + hasError: boolean; + canEdit: boolean; + onView: (template: CompletionTemplate) => void; + onEdit: (template: CompletionTemplate) => void; +}; + +function RowAction({ + label, + icon: Icon, + onClick, +}: { + label: string; + icon: typeof Eye; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyRow() { + return ( + + +
+ + + {COMPLETION_TEMPLATE_COPY.emptySearchTitle} + + + {COMPLETION_TEMPLATE_COPY.emptySearchHint} + +
+
+
+ ); +} + +function TemplateRow({ + template, + canEdit, + onView, + onEdit, +}: Omit & { + template: CompletionTemplate; +}) { + return ( + onView(template)} sx={{ cursor: "pointer", height: 52 }}> + + + + + + {template.name} + + + + {formatTemplateDate(template.lastUpdatedAt)} + + + + + + + onView(template)} /> + {canEdit && onEdit(template)} />} + + + + ); +} + +export function CompletionTemplatesTable({ + templates, + isLoading, + hasError, + canEdit, + onView, + onEdit, +}: CompletionTemplatesTableProps) { + return ( + + + + Document Title + Last Updated + Created By + + Actions + + + + + {isLoading && } + {!isLoading && !hasError && templates.length === 0 && } + {templates.map((template) => ( + + ))} + +
+ ); +} diff --git a/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx new file mode 100644 index 00000000..6ec9a5e4 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/delete-completion-template-dialog.tsx @@ -0,0 +1,98 @@ +import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { useNavigate } from "react-router"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { Text } from "@/components/ui/text"; +import type { + CompletionTemplate, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; +import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations"; +import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +type DeleteCompletionTemplateDialogProps = { + template: CompletionTemplate; + open: boolean; + onClose: () => void; + onDeleted: () => void; +}; + +function deleteMessage(name: string, linked: LinkedWorkOrders): string { + if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`; + const plural = linked.count === 1 ? "" : "s"; + return ( + `This will permanently remove "${name}". It's still linked to ${linked.count} open work ` + + `order${plural} through its Service — deleting it won't change that Service, but new ` + + "documents generated for those work orders will fall back to a generic procedure until " + + "reassigned. This cannot be undone." + ); +} + +function LinkedWorkOrdersMessage({ + name, + linked, +}: { + name: string; + linked: ReturnType; +}) { + if (linked.isPending) { + return ( +
+ + + Checking linked work orders… + +
+ ); + } + if (linked.isError) { + return ( + + Couldn't check which work orders use this template. Close this and try again. + + ); + } + return {deleteMessage(name, linked.data)}; +} + +/** Deleting always asks first, and names the open work orders that still depend on the template. */ +export function DeleteCompletionTemplateDialog({ + template, + open, + onClose, + onDeleted, +}: DeleteCompletionTemplateDialogProps) { + const navigate = useNavigate(); + const linked = useLinkedWorkOrders(open ? template.id : null); + const remove = useDeleteCompletionTemplate(); + const linkedIds = linked.data?.workOrderIds ?? []; + + function confirmDelete() { + remove.mutate(template.id, { onSuccess: onDeleted }); + } + + return ( + + Delete this template? + + + + + + {linkedIds.length > 0 && ( + + )} + + + + ); +} diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts new file mode 100644 index 00000000..2c0e731c --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-dialog.ts @@ -0,0 +1,109 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import { + toCompletionTemplateFormValues, + toCompletionTemplateInput, +} from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { + completionTemplateFormSchema, + type CompletionTemplateFormValues, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { + isTitleRequiredError, + useCreateCompletionTemplate, + useUpdateCompletionTemplate, +} from "@/domain/completion-templates/use-cases/use-completion-template-mutations"; +import type { CompletionTemplatePreviewValues } from "./completion-template-preview"; + +export type CompletionTemplateDialogMode = "create" | "view" | "edit"; + +type UseCompletionTemplateDialogOptions = { + template: CompletionTemplate | null; + initialMode: CompletionTemplateDialogMode; + onClose: () => void; + onSaved: (template: CompletionTemplate) => void; +}; + +function usePreviewValues( + control: ReturnType>["control"], +): CompletionTemplatePreviewValues { + const watched = useWatch({ control }); + return { + name: watched.name ?? "", + extraSafetyNote: watched.extraSafetyNote ?? null, + procedures: (watched.procedures ?? []).map((procedure) => ({ + name: procedure?.name ?? "", + description: procedure?.description ?? "", + })), + }; +} + +export function useCompletionTemplateDialog({ + template, + initialMode, + onClose, + onSaved, +}: UseCompletionTemplateDialogOptions) { + const [mode, setMode] = useState( + template === null ? "create" : initialMode, + ); + const form = useForm({ + resolver: zodResolver(completionTemplateFormSchema), + defaultValues: toCompletionTemplateFormValues(template), + }); + const previewValues = usePreviewValues(form.control); + const create = useCreateCompletionTemplate(); + const update = useUpdateCompletionTemplate(); + + function showServerTitleError(error: Error) { + if (!isTitleRequiredError(error)) return; + form.setError( + "name", + { message: COMPLETION_TEMPLATE_COPY.titleRequired }, + { shouldFocus: true }, + ); + } + + const submit = form.handleSubmit((values) => { + const input = toCompletionTemplateInput(values); + if (template === null) { + create.mutate(input, { onSuccess: onClose, onError: showServerTitleError }); + return; + } + update.mutate( + { template, input }, + { + onSuccess: (saved) => { + form.reset(toCompletionTemplateFormValues(saved)); + setMode("view"); + onSaved(saved); + }, + onError: showServerTitleError, + }, + ); + }); + + /** Discards unsaved edits: back to the template as it was, or closed when creating. */ + function cancel() { + if (template === null) { + onClose(); + return; + } + form.reset(toCompletionTemplateFormValues(template)); + setMode("view"); + } + + return { + mode, + isEditing: mode !== "view", + form, + previewValues, + submit, + cancel, + startEditing: () => setMode("edit"), + isSaving: create.isPending || update.isPending, + }; +} diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts new file mode 100644 index 00000000..98534285 --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts @@ -0,0 +1,36 @@ +import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions"; +import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; +import { useAuthContext } from "@/providers/auth-context"; + +const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"]; + +export interface CompletionTemplatePermissions { + canCreate: boolean; + canEdit: boolean; + canDelete: boolean; +} + +function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] { + const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase()); + return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase())); +} + +/** + * The signed-in user's session carries roles, not per-user permission overrides, so each key + * resolves to the role default the Team Members screen and the server policy start from. + */ +export function completionTemplatePermissions( + userRoles: string | null | undefined, +): CompletionTemplatePermissions { + const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)])); + return { + canCreate: granted.has("createCompletionDocTemplates"), + canEdit: granted.has("editCompletionDocTemplates"), + canDelete: granted.has("deleteCompletionDocTemplates"), + }; +} + +export function useCompletionTemplatePermissions(): CompletionTemplatePermissions { + const { user } = useAuthContext(); + return completionTemplatePermissions(user?.userRoles); +} diff --git a/src/app/(protected)/completion-docs/index.tsx b/src/app/(protected)/completion-docs/index.tsx new file mode 100644 index 00000000..a17139c3 --- /dev/null +++ b/src/app/(protected)/completion-docs/index.tsx @@ -0,0 +1,99 @@ +import { Box } from "@mui/material"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { + COMPLETION_TEMPLATE_COPY, + templateCountLabel, +} from "@/domain/completion-templates/constants/completion-template-constants"; +import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template"; +import { useCompletionTemplates } from "@/domain/completion-templates/use-cases/use-completion-templates"; +import { useDebounce } from "@/hooks/use-debounce"; +import { CompletionTemplateDialog } from "./_components/completion-template-dialog"; +import { CompletionTemplatesTable } from "./_components/completion-templates-table"; +import type { CompletionTemplateDialogMode } from "./_components/use-completion-template-dialog"; +import { useCompletionTemplatePermissions } from "./_components/use-completion-template-permissions"; + +type OpenDialog = { template: CompletionTemplate | null; mode: CompletionTemplateDialogMode }; + +export default function CompletionDocsPage() { + const permissions = useCompletionTemplatePermissions(); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 250); + const all = useCompletionTemplates(); + const filtered = useCompletionTemplates(debouncedSearch); + const [dialog, setDialog] = useState(null); + const templates = filtered.data ?? []; + + return ( + + + + {permissions.canCreate && ( + + )} + + } + /> + + {filtered.error !== null && ( + void filtered.refetch()} + /> + )} + +
+ setDialog({ template, mode: "view" })} + onEdit={(template) => setDialog({ template, mode: "edit" })} + /> +
+
+ + {templateCountLabel(templates.length)} + +
+ + {dialog !== null && ( + setDialog(null)} + onSaved={(template) => setDialog({ template, mode: "view" })} + /> + )} +
+ ); +} diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx deleted file mode 100644 index ed87aa3c..00000000 --- a/src/app/(protected)/locations/_components/location-delete-dialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import type { LocationListItem } from "@/domain/locations/types/location"; - -interface LocationDeleteDialogProps { - isPending: boolean; - onClose: () => void; - onConfirm: () => void; - target: LocationListItem | null; -} - -export function LocationDeleteDialog({ - isPending, - onClose, - onConfirm, - target, -}: LocationDeleteDialogProps) { - return ( - - Delete this site? - - - This will permanently remove {target?.name} from Sites. This cannot be undone. - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index 840a06c0..a7332a76 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -1,8 +1,6 @@ -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import { Button, CircularProgress, - IconButton, Paper, Table, TableBody, @@ -10,7 +8,6 @@ import { TableContainer, TableHead, TableRow, - Tooltip, Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; @@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { isLoading: boolean; - onDelete: (row: LocationListItem) => void; onOpen: (row: LocationListItem) => void; rows: LocationListItem[]; selectedId?: string | null; } -const COLUMN_COUNT = 8; +const COLUMN_COUNT = 7; export function LocationsTable({ isLoading, - onDelete, onOpen, rows, selectedId = null, @@ -46,9 +41,6 @@ export function LocationsTable({ State POC Name POC Phone - - Actions - @@ -98,20 +90,6 @@ export function LocationsTable({ {row.phone || "—"} - - - { - event.stopPropagation(); - onDelete(row); - }} - > - - - - ))} diff --git a/src/app/(protected)/locations/_components/site-delete-dialog.tsx b/src/app/(protected)/locations/_components/site-delete-dialog.tsx new file mode 100644 index 00000000..5b7ba58b --- /dev/null +++ b/src/app/(protected)/locations/_components/site-delete-dialog.tsx @@ -0,0 +1,110 @@ +import { useNavigate } from "react-router"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; +import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders"; +import { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +interface SiteDeleteDialogProps { + open: boolean; + siteId: string; + siteCode: string; + onClose: () => void; + onDeleted: () => void; +} + +const CHECK_FAILED_MESSAGE = + "Couldn't check this site for open work orders. Try again before deleting it."; + +function describeDeletion(siteCode: string, openCount: number): string { + if (openCount === 0) { + return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`; + } + const noun = openCount === 1 ? "work order" : "work orders"; + return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`; +} + +/** Delete confirmation; warns with the open work-order count and links to those work orders. */ +export function SiteDeleteDialog({ + open, + siteId, + siteCode, + onClose, + onDeleted, +}: SiteDeleteDialogProps) { + const navigate = useNavigate(); + const deleteLocation = useDeleteLocation(); + const { + data: openWorkOrders, + isLoading, + isError, + isFetching, + refetch, + } = useSiteOpenWorkOrders(siteId, open); + const openCount = openWorkOrders?.count ?? 0; + // Without a current count the warning cannot be trusted, so Delete waits for a successful check. + // `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands. + const countUnknown = isLoading || isFetching || isError; + // The server caps the id list; when it doesn't cover the stated count, open the site's whole board. + const openIds = openWorkOrders?.workOrderIds ?? []; + const openWorkOrdersHref = + openIds.length > 0 && openIds.length >= openCount + ? buildWorkOrderIdsHref(openIds) + : buildSiteOpenWorkOrdersHref(siteId); + + return ( + { + if (!deleteLocation.isPending) { + onClose(); + } + }} + aria-labelledby="site-delete-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + Delete this site? + + {isFetching && !isError && ( + + )} + {isError && {CHECK_FAILED_MESSAGE}} + {!countUnknown && ( + {describeDeletion(siteCode, openCount)} + )} + + + + {isError && ( + + )} + {!countUnknown && openCount > 0 && ( + + )} + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx index 3111bfa5..06e78919 100644 --- a/src/app/(protected)/locations/_components/site-detail-panel.tsx +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -1,8 +1,11 @@ -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; import { IconCloseButton } from "@/components/ui/icon-close-button"; import type { Location } from "@/domain/locations/types/location"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { canDeleteSites } from "@/lib/auth/user-utils"; +import { AuthContext } from "@/providers/auth-context"; +import { SiteDeleteDialog } from "./site-delete-dialog"; import { SiteDetailEdit } from "./site-detail-edit"; import { SiteDetailView } from "./site-detail-view"; @@ -36,6 +39,8 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () = function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) { const [mode, setMode] = useState<"view" | "edit">("view"); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles); const { data: location, isLoading, error } = useLocationDetail(siteId); return ( @@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) className="border-t px-4 py-3" sx={{ justifyContent: "flex-end", borderColor: "divider" }} > + {canDelete && ( + + )} + setIsDeleteOpen(false)} + onDeleted={onClose} + /> )} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 52308aad..6492b96b 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -9,14 +9,11 @@ import { TextField, Typography, } from "@mui/material"; -import type { LocationListItem } from "@/domain/locations/types/location"; -import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; import { AddSiteDialog } from "./_components/add-site-dialog"; import { AreaFacet } from "./_components/area-facet"; -import { LocationDeleteDialog } from "./_components/location-delete-dialog"; import { LocationsTable } from "./_components/locations-table"; import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel"; @@ -25,12 +22,10 @@ export default function LocationsListPage() { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); const [selectedAreas, setSelectedAreas] = useState([]); - const [deleteTarget, setDeleteTarget] = useState(null); const [isAddOpen, setIsAddOpen] = useState(false); const [selectedId, setSelectedId] = useState(null); const debouncedSearch = useDebounce(search, 300); - const deleteLocation = useDeleteLocation(); const closePanel = useCallback(() => setSelectedId(null), []); const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); @@ -50,20 +45,6 @@ export default function LocationsListPage() { const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; - const handleDeleteConfirm = () => { - if (!deleteTarget) { - return; - } - - const deletedId = String(deleteTarget.id); - deleteLocation.mutate(deleteTarget.id, { - onSuccess: () => { - setDeleteTarget(null); - setSelectedId((current) => (current === deletedId ? null : current)); - }, - }); - }; - return ( setSelectedId(String(row.id))} - onDelete={setDeleteTarget} /> setIsAddOpen(false)} /> - setDeleteTarget(null)} - onConfirm={handleDeleteConfirm} - /> ); } diff --git a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts new file mode 100644 index 00000000..eefc3bf1 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts @@ -0,0 +1,52 @@ +export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const; +export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number]; + +export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab { + return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +// A rejection is terminal, so the archive ends with the decision and its reason +// instead of an Actions column. +const REJECTED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Rejected By", + "Rejected At", + "Rejection Reason", +] as const; + +export const UPLIFT_APPROVAL_HEADER_CELLS: Record = { + pending: PENDING_HEADER_CELLS, + approved: APPROVED_HEADER_CELLS, + rejected: REJECTED_HEADER_CELLS, +}; diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 3f52d36b..9819d2cd 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -16,14 +16,20 @@ import { formatUpliftMoney, isGrantedAmount, } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { + UPLIFT_APPROVAL_HEADER_CELLS, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { + RejectedEmptyState, + RejectedRowCells, +} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells"; import { Text } from "@/components/ui/text"; import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; -export type UpliftApprovalTab = "pending" | "approved"; - const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; @@ -236,65 +242,84 @@ function UpliftApprovalRow({ {row.requestedByVendorName || "—"} - {tab === "pending" ? ( - - - - ) : ( + {tab === "pending" && ( + <> + + + + + + + + )} + {tab === "approved" && ( <> {row.decidedByName || "—"} {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + )} - - {tab === "pending" ? ( - - ) : ( - - )} + {tab === "rejected" && } + + ); +} + +function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) { + if (tab === "rejected") return ; + if (tab === "approved") { + return ( + + No approved uplifts yet + + Uplifts you approve are archived here for reference. + + + ); + } + return ( + + No uplifts waiting for approval. + + ); +} + +// A failed queue is not an empty one: the page's error alert explains it, so the +// empty-state copy must not also claim the list has nothing in it. +function UpliftApprovalsEmptyRow({ + tab, + colSpan, + hasError, +}: { + tab: UpliftApprovalTab; + colSpan: number; + hasError: boolean; +}) { + if (hasError) return null; + return ( + + + ); } -const PENDING_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Waiting", - "Actions", -] as const; - -const APPROVED_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Approved By", - "Approved At", - "Actions", -] as const; - export function UpliftApprovalsTable({ tab, isLoading, + hasError, rows, canRevoke, onOpenDetail, @@ -306,6 +331,7 @@ export function UpliftApprovalsTable({ }: { tab: UpliftApprovalTab; isLoading: boolean; + hasError: boolean; rows: UpliftQueueItem[]; canRevoke: boolean; onOpenDetail: (row: UpliftQueueItem) => void; @@ -315,7 +341,7 @@ export function UpliftApprovalsTable({ onOpenAttachment: (row: UpliftQueueItem) => void; isDecisionPending: boolean; }) { - const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab]; return ( @@ -336,22 +362,7 @@ export function UpliftApprovalsTable({ ) : rows.length === 0 ? ( - - - {tab === "pending" ? ( - - No uplifts waiting for approval. - - ) : ( - - No approved uplifts yet - - Uplifts you approve are archived here for reference. - - - )} - - + ) : ( rows.map((row) => ( + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + {row.decisionNote || "—"} + + + + ); +} + +export function RejectedEmptyState() { + return ( + + + No rejected uplifts. + + ); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index db538443..2aaf0005 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - UpliftApprovalsTable, + UPLIFT_APPROVAL_TABS, + isUpliftApprovalTab, type UpliftApprovalTab, -} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { @@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; +import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; const PAGE_SIZE = 25; @@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: ); } +const TAB_LABELS: Record = { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +}; + +// A tab's total is only known once its queue has loaded at least once; until then the +// pill and pager must not claim a count (a loading or failed queue is not an empty one). +// A failed refetch keeps the last loaded page, so its total stays usable. +function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null { + return query.data ? query.data.totalCount : null; +} + +function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) { + return ( + + ); +} + function UpliftApprovalsTabs({ tab, - pendingCount, + counts, onChange, }: { tab: UpliftApprovalTab; - pendingCount: number; + counts: Record; onChange: (_event: unknown, value: string) => void; }) { return ( - - Pending - - - } - /> - + {UPLIFT_APPROVAL_TABS.map((value) => ( + + {TAB_LABELS[value]} + + + } + /> + ))} ); } function UpliftApprovalsPagination({ - tab, count, - pendingPage, - approvedPage, - onPendingPageChange, - onApprovedPageChange, + page, + onPageChange, }: { - tab: UpliftApprovalTab; count: number; - pendingPage: number; - approvedPage: number; - onPendingPageChange: (page: number) => void; - onApprovedPageChange: (page: number) => void; + page: number; + onPageChange: (page: number) => void; }) { return ( - tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) - } + page={page} + onPageChange={(_event, nextPage) => onPageChange(nextPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} /> @@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) { export default function UpliftQueuePage() { const [tab, setTab] = useState("pending"); - const [pendingPage, setPendingPage] = useState(0); - const [approvedPage, setApprovedPage] = useState(0); + const [pages, setPages] = useState>({ + pending: 0, + approved: 0, + rejected: 0, + }); const { user } = useAuthContext(); const canRevokeUplifts = isAdminUser(user?.userRoles); const pendingQuery = useUpliftsQueue({ status: "Pending", - page: pendingPage + 1, + page: pages.pending + 1, pageSize: PAGE_SIZE, }); const approvedQuery = useUpliftsQueue({ status: "Approved", - page: approvedPage + 1, + page: pages.approved + 1, pageSize: PAGE_SIZE, }); + const rejectedQuery = useUpliftsQueue({ + status: "Rejected", + page: pages.rejected + 1, + pageSize: PAGE_SIZE, + }); + const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery }; const controller = useUpliftApprovalController(); const { @@ -138,13 +163,18 @@ export default function UpliftQueuePage() { openDetail, } = controller; - const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const activeQuery = queries[tab]; const rows = activeQuery.data?.items ?? []; - const pendingCount = pendingQuery.data?.totalCount ?? 0; + const counts = { + pending: knownTotal(pendingQuery), + approved: knownTotal(approvedQuery), + rejected: knownTotal(rejectedQuery), + }; + const activeTotal = counts[tab]; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; const handleTabChange = (_event: unknown, value: string) => { - if (value === "pending" || value === "approved") { + if (isUpliftApprovalTab(value)) { setTab(value); } }; @@ -152,11 +182,12 @@ export default function UpliftQueuePage() { return ( - + - + {activeTotal != null && ( + setPages((current) => ({ ...current, [tab]: page }))} + /> + )} {detailItem != null && ( +
+
+
{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..b1f79c66 --- /dev/null +++ b/src/domain/completion-templates/constants/completion-template-constants.ts @@ -0,0 +1,27 @@ +/** Filled in on every generated document; shown read-only so authors don't retype them. */ +export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const; + +/** Server limits; the inputs stop there instead of failing the save. */ +export const PROCEDURE_NAME_MAX_LENGTH = 200; +export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000; + +export const COMPLETION_TEMPLATE_COPY = { + pageTitle: "Completion Docs", + searchPlaceholder: "Search document title…", + addTemplate: "Add Template", + saveChanges: "Save changes", + titleRequired: "Document Title is required", + noProcedureDetail: "No procedure defined yet", + noProcedurePreview: "No procedure added yet.", + untitledDocument: "UNTITLED COMPLETION DOCUMENT", + autoFilledSuffix: "— auto-filled per work order", + emptySearchTitle: "No templates found", + emptySearchHint: "Try a different search, or add a new template.", + added: "Template added", + updated: "Template updated", + deleted: "Template deleted", +} as const; + +export function templateCountLabel(count: number): string { + return `${count} template${count === 1 ? "" : "s"}`; +} diff --git a/src/domain/completion-templates/mappers/completion-template-mapper.ts b/src/domain/completion-templates/mappers/completion-template-mapper.ts new file mode 100644 index 00000000..4b0188cb --- /dev/null +++ b/src/domain/completion-templates/mappers/completion-template-mapper.ts @@ -0,0 +1,63 @@ +import type { + CompletionTemplateDto, + CompletionTemplateFormValues, +} from "@/domain/completion-templates/schemas/completion-template-schema"; +import type { + CompletionTemplate, + CompletionTemplateInput, +} from "@/domain/completion-templates/types/completion-template"; + +export function mapCompletionTemplate(dto: CompletionTemplateDto): CompletionTemplate { + const createdAt = dto.createdAt ?? ""; + const procedures = [...(dto.procedures ?? [])] + .map((procedure, index) => ({ procedure, order: procedure.sortOrder ?? index })) + .sort((a, b) => a.order - b.order) + .map(({ procedure }) => ({ + name: procedure.name ?? "", + description: procedure.description ?? "", + })); + + return { + id: dto.id, + name: dto.name ?? "", + extraSafetyNote: dto.extraSafetyNote?.trim() ? dto.extraSafetyNote : null, + procedures, + createdAt, + lastUpdatedAt: dto.updatedAt ?? createdAt, + createdByName: dto.createdByName?.trim() ? dto.createdByName : null, + legacy: { + serviceKey: dto.serviceKey ?? "", + workOrderType: dto.workOrderType ?? null, + templateUrl: dto.templateUrl ?? "", + isActive: dto.isActive ?? true, + }, + }; +} + +export function toCompletionTemplateFormValues( + template: CompletionTemplate | null, +): CompletionTemplateFormValues { + if (!template) return { name: "", extraSafetyNote: null, procedures: [] }; + return { + name: template.name, + extraSafetyNote: template.extraSafetyNote, + procedures: template.procedures.map((procedure) => ({ ...procedure })), + }; +} + +/** Trims every field and drops procedures the author left entirely blank. */ +export function toCompletionTemplateInput( + values: CompletionTemplateFormValues, +): CompletionTemplateInput { + const note = values.extraSafetyNote?.trim() ?? ""; + return { + name: values.name.trim(), + extraSafetyNote: note === "" ? null : note, + procedures: values.procedures + .map((procedure) => ({ + name: procedure.name.trim(), + description: procedure.description.trim(), + })) + .filter((procedure) => procedure.name !== "" || procedure.description !== ""), + }; +} diff --git a/src/domain/completion-templates/schemas/completion-template-schema.ts b/src/domain/completion-templates/schemas/completion-template-schema.ts new file mode 100644 index 00000000..7d6605ab --- /dev/null +++ b/src/domain/completion-templates/schemas/completion-template-schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; + +const procedureDtoSchema = z.object({ + name: z.string().nullish(), + description: z.string().nullish(), + sortOrder: z.number().nullish(), +}); + +export const completionTemplateDtoSchema = z.object({ + id: z.number(), + name: z.string().nullish(), + extraSafetyNote: z.string().nullish(), + procedures: z.array(procedureDtoSchema).nullish(), + createdAt: z.string().nullish(), + updatedAt: z.string().nullish(), + createdByName: z.string().nullish(), + serviceKey: z.string().nullish(), + workOrderType: z.number().nullish(), + templateUrl: z.string().nullish(), + isActive: z.boolean().nullish(), +}); + +export const completionTemplateListDtoSchema = z.array(completionTemplateDtoSchema); + +export const linkedWorkOrdersDtoSchema = z.object({ + count: z.number(), + workOrderIds: z.array(z.number()), +}); + +export type CompletionTemplateDto = z.infer; + +const procedureFormSchema = z.object({ + name: z.string(), + description: z.string(), +}); + +export const completionTemplateFormSchema = z.object({ + name: z.string().trim().min(1, COMPLETION_TEMPLATE_COPY.titleRequired), + /** `null` until the author adds the optional note. */ + extraSafetyNote: z.string().nullable(), + procedures: z.array(procedureFormSchema), +}); + +export type CompletionTemplateFormValues = z.infer; diff --git a/src/domain/completion-templates/types/completion-template.ts b/src/domain/completion-templates/types/completion-template.ts new file mode 100644 index 00000000..ec1a28b5 --- /dev/null +++ b/src/domain/completion-templates/types/completion-template.ts @@ -0,0 +1,38 @@ +export interface CompletionTemplateProcedure { + name: string; + description: string; +} + +/** Fields the Services registry still reads; an edit sends them back unchanged. */ +export interface CompletionTemplateLegacyFields { + serviceKey: string; + workOrderType: number | null; + templateUrl: string; + isActive: boolean; +} + +export interface CompletionTemplate { + id: number; + /** The Document Title. */ + name: string; + extraSafetyNote: string | null; + /** In print order. */ + procedures: CompletionTemplateProcedure[]; + createdAt: string; + /** `updatedAt ?? createdAt`: what the table shows as Last Updated. */ + lastUpdatedAt: string; + createdByName: string | null; + legacy: CompletionTemplateLegacyFields; +} + +export interface CompletionTemplateInput { + name: string; + extraSafetyNote: string | null; + procedures: CompletionTemplateProcedure[]; +} + +/** Open work orders whose completion document would be generated from a template. */ +export interface LinkedWorkOrders { + count: number; + workOrderIds: number[]; +} diff --git a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts new file mode 100644 index 00000000..83010329 --- /dev/null +++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts @@ -0,0 +1,85 @@ +import { + useMutation, + useQueryClient, + type QueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { isHTTPError } from "ky"; +import { toast } from "react-toastify"; +import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api"; +import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants"; +import type { + CompletionTemplate, + CompletionTemplateInput, +} from "@/domain/completion-templates/types/completion-template"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateCompletionTemplateVariables { + template: CompletionTemplate; + input: CompletionTemplateInput; +} + +/** The server's blank-title rejection, which the form shows on the title field instead. */ +export function isTitleRequiredError(error: Error): boolean { + if (!isHTTPError(error) || error.response.status !== 422) return false; + const body: unknown = error.data; + return ( + typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired" + ); +} + +function invalidateTemplates(queryClient: QueryClient) { + void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all }); + // The Services registry lists template names and clears links to a deleted template. + void queryClient.invalidateQueries({ queryKey: queryKeys.services.all }); +} + +function toastFailure(error: Error, fallback: string) { + if (isTitleRequiredError(error)) return; + toast.error(error.message || fallback); +} + +export function useCreateCompletionTemplate(): UseMutationResult< + CompletionTemplate, + Error, + CompletionTemplateInput +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CompletionTemplateInput) => completionTemplatesApi.create(input), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.added); + }, + onError: (error: Error) => toastFailure(error, "Failed to add template"), + }); +} + +export function useUpdateCompletionTemplate(): UseMutationResult< + CompletionTemplate, + Error, + UpdateCompletionTemplateVariables +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ template, input }: UpdateCompletionTemplateVariables) => + completionTemplatesApi.update(template, input), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.updated); + }, + onError: (error: Error) => toastFailure(error, "Failed to update template"), + }); +} + +export function useDeleteCompletionTemplate(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => completionTemplatesApi.delete(id), + onSuccess: () => { + invalidateTemplates(queryClient); + toast.success(COMPLETION_TEMPLATE_COPY.deleted); + }, + onError: (error: Error) => toastFailure(error, "Failed to delete template"), + }); +} diff --git a/src/domain/completion-templates/use-cases/use-completion-templates.ts b/src/domain/completion-templates/use-cases/use-completion-templates.ts new file mode 100644 index 00000000..f3101016 --- /dev/null +++ b/src/domain/completion-templates/use-cases/use-completion-templates.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api"; +import type { + CompletionTemplate, + LinkedWorkOrders, +} from "@/domain/completion-templates/types/completion-template"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** `search` is a partial, case-insensitive Document Title match done by the server. */ +export function useCompletionTemplates(search = ""): UseQueryResult { + const term = search.trim(); + return useQuery({ + queryKey: queryKeys.completionTemplates.list(term), + queryFn: () => completionTemplatesApi.list(term), + placeholderData: keepPreviousData, + }); +} + +/** Always refetched when asked for: the count gates a destructive action. */ +export function useLinkedWorkOrders( + templateId: number | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0), + queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0), + enabled: templateId !== null, + staleTime: 0, + refetchOnMount: "always", + }); +} diff --git a/src/domain/completion-templates/utils/description-lines.ts b/src/domain/completion-templates/utils/description-lines.ts new file mode 100644 index 00000000..d9a2a047 --- /dev/null +++ b/src/domain/completion-templates/utils/description-lines.ts @@ -0,0 +1,7 @@ +/** A procedure description prints one paragraph per non-blank line. */ +export function descriptionLines(description: string): string[] { + return description + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} diff --git a/src/domain/completion-templates/utils/format-template-date.ts b/src/domain/completion-templates/utils/format-template-date.ts new file mode 100644 index 00000000..042f061b --- /dev/null +++ b/src/domain/completion-templates/utils/format-template-date.ts @@ -0,0 +1,6 @@ +/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */ +export function formatTemplateDate(value: string): string { + const date = new Date(value); + if (value === "" || Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 3c122e5a..fc1a5f60 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -7,11 +7,13 @@ import { mapLocationListItems, mapLocationSites, mapLocationToBackend, + mapSiteOpenWorkOrders, } from "@/domain/locations/mappers/location-mapper"; import type { Location, LocationSite, SiteContactInfoInput, + SiteOpenWorkOrders, LocationsListParams, LocationsListResult, } from "@/domain/locations/types/location"; @@ -84,4 +86,9 @@ export const locationsApi = { delete: async (id: string | number): Promise => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, + + getOpenWorkOrders: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.locations}/${id}/open-work-orders`); + return mapSiteOpenWorkOrders(handleApiResponse(data)); + }, }; diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 99241eb0..dfb0a25f 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -3,6 +3,7 @@ import type { LocationContact, LocationListItem, LocationSite, + SiteOpenWorkOrders, } from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { @@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record return payload; } + +export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders { + const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const rawIds = item.workOrderIds ?? item.WorkOrderIds; + const workOrderIds = Array.isArray(rawIds) + ? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0) + : []; + const count = item.count ?? item.Count; + return { + count: typeof count === "number" && count >= 0 ? count : workOrderIds.length, + workOrderIds, + }; +} diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index b4ba7dc7..d9d30fbb 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -75,3 +75,9 @@ export interface SiteContactInfoInput { contacts: LocationContact[]; notes: string; } + +/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */ +export interface SiteOpenWorkOrders { + count: number; + workOrderIds: number[]; +} diff --git a/src/domain/locations/use-cases/use-site-open-work-orders.ts b/src/domain/locations/use-cases/use-site-open-work-orders.ts new file mode 100644 index 00000000..f405c7ff --- /dev/null +++ b/src/domain/locations/use-cases/use-site-open-work-orders.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { SiteOpenWorkOrders } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */ +export function useSiteOpenWorkOrders( + siteId: string, + enabled: boolean, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.locations.openWorkOrders(siteId), + queryFn: () => locationsApi.getOpenWorkOrders(siteId), + enabled, + staleTime: 0, + }); +} diff --git a/src/domain/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts index 9ebcf4bd..256250ad 100644 --- a/src/domain/uplifts/use-cases/use-uplifts-queue.ts +++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts @@ -10,5 +10,8 @@ export function useUpliftsQueue( queryKey: queryKeys.uplifts.queue(params as Record), queryFn: () => upliftsApi.list(params), placeholderData: keepPreviousData, + // The Uplift Approvals page reports a failed queue inline, so the global toast + // would announce the same failure twice. + meta: { suppressErrorToast: true }, }); } diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index 2334b9b6..ebc73bee 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -61,6 +61,18 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { ); } +/** Open = every lifecycle status except Completed (Canceled is not a board status). */ +const OPEN_STATUSES = ALL_WIZARD_STATUSES.filter((status) => status !== "Completed"); + +/** Work Orders filtered to one site's open work orders; `sites` takes the location id. */ +export function buildSiteOpenWorkOrdersHref(siteId: string | number): string { + const params = new URLSearchParams({ + sites: String(siteId), + statuses: OPEN_STATUSES.join(","), + }); + return `/workorders?${params.toString()}`; +} + export function parseDashboardDrilldownParams( params: URLSearchParams, ): WorkOrderAdvancedFilters | null { @@ -79,6 +91,7 @@ export function parseDashboardDrilldownParams( const types = parseTypes(params.get("types")); const dispatchers = parseList(params.get("dispatchers")); const regions = parseRegions(params.get("regions")); + const sites = [...new Set(parseList(params.get("sites")))]; const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); const hasUplift = params.get("hasUplift") === "1"; @@ -90,6 +103,7 @@ export function parseDashboardDrilldownParams( types.length, dispatchers.length, regions.length, + sites.length, upliftStatuses.length, hasUplift, ].some(Boolean); @@ -122,6 +136,9 @@ export function parseDashboardDrilldownParams( if (regions.length > 0) { filters.regions = new Set(regions); } + if (sites.length > 0) { + filters.sites = new Set(sites); + } if (upliftStatuses.length > 0) { filters.upliftStatuses = new Set(upliftStatuses); } diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index eb8a0664..3368da22 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -81,6 +81,12 @@ export const queryKeys = { byWorkOrderType: (workOrderType: string) => [...queryKeys.services.all, "byWorkOrderType", workOrderType] as const, }, + completionTemplates: { + all: ["completionTemplates"] as const, + list: (search: string) => [...queryKeys.completionTemplates.all, "list", search] as const, + linkedWorkOrders: (id: number) => + [...queryKeys.completionTemplates.all, "linkedWorkOrders", id] as const, + }, locations: { all: ["locations"] as const, list: (params?: Record) => @@ -88,6 +94,8 @@ export const queryKeys = { detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const, dropdown: () => [...queryKeys.locations.all, "dropdown"] as const, sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const, + openWorkOrders: (id: string | number) => + [...queryKeys.locations.all, "open-work-orders", id] as const, }, vendors: { all: ["vendors"] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 035e91c0..e67c7bbf 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde const roles = userRoles.split(",").map((role) => role.trim().toLowerCase()); return roles.includes("admin") || roles.includes("scheduler"); } + +/** + * Site deletion is limited to Admin and Scheduler (the server enforces the DeleteSites team + * permission; this only hides the control from roles that would get a 403). + */ +export function canDeleteSites(userRoles: string | null | undefined): boolean { + return hasUserRole(userRoles, "admin") || hasUserRole(userRoles, "scheduler"); +} diff --git a/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts new file mode 100644 index 00000000..15e7909c --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts @@ -0,0 +1,90 @@ +import type { Mock } from "vitest"; +import type { CompletionTemplateDto } from "@/domain/completion-templates/schemas/completion-template-schema"; + +export interface ApiMocks { + apiGet: Mock; + apiPost: Mock; + apiPut: Mock; + apiDeleteNoContent: Mock; +} + +interface WriteBody { + name: string; + extraSafetyNote: string | null; + procedures: { name: string; description: string }[]; +} + +export function templateDto(overrides: Partial = {}): CompletionTemplateDto { + return { + id: 12, + name: "Backflow Test Report", + extraSafetyNote: null, + procedures: [], + createdAt: "2026-09-01T13:00:00Z", + updatedAt: null, + createdByName: "Jane Doe", + serviceKey: "backflow", + workOrderType: 2, + templateUrl: "https://files.example/backflow.pdf", + isActive: true, + ...overrides, + }; +} + +export const BACKFLOW = templateDto({ + id: 12, + name: "Backflow Test Report", + extraSafetyNote: "Wear gloves", + procedures: [ + { name: "Shut-off", description: "Close valve", sortOrder: 1 }, + { name: "Backflow Procedure", description: "line 1\nline 2", sortOrder: 0 }, + ], + updatedAt: "2026-09-25T14:00:00Z", +}); + +export const SCRUBBER = templateDto({ + id: 13, + name: "Scrubber Pit Augering", + createdByName: null, + createdAt: "2026-06-01T10:00:00Z", +}); + +function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) { + return templateDto({ + ...base, + id, + name: body.name, + extraSafetyNote: body.extraSafetyNote, + procedures: body.procedures.map((procedure, index) => ({ ...procedure, sortOrder: index })), + updatedAt: "2026-09-25T15:00:00Z", + }); +} + +/** Answers the template endpoints the way the backend contract describes. */ +export function serveTemplates( + api: ApiMocks, + templates: CompletionTemplateDto[], + linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] }, +) { + api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => { + if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked); + const term = options?.searchParams?.search?.toLowerCase() ?? ""; + return Promise.resolve( + templates.filter((template) => (template.name ?? "").toLowerCase().includes(term)), + ); + }); + api.apiPost.mockImplementation((_url: string, body: WriteBody) => + Promise.resolve(savedDto(99, body)), + ); + api.apiPut.mockImplementation((url: string, body: WriteBody) => { + const id = Number(url.split("/").pop()); + return Promise.resolve( + savedDto( + id, + body, + templates.find((template) => template.id === id), + ), + ); + }); + api.apiDeleteNoContent.mockResolvedValue(undefined); +} diff --git a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx new file mode 100644 index 00000000..30f1070c --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -0,0 +1,167 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; +import { renderWithProviders } from "@/test/test-utils"; +import { BACKFLOW, SCRUBBER, serveTemplates, templateDto } from "./completion-docs-fixtures"; + +const api = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDeleteNoContent: vi.fn(), +})); +const auth = vi.hoisted(() => ({ userRoles: "Admin" })); + +vi.mock("@/api/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: "u1", userRoles: auth.userRoles } }), +})); + +// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down. +let user = userEvent.setup({ delay: null }); + +function renderPage() { + return renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + }); +} + +async function openTemplate(name: string) { + await user.click(await screen.findByRole("row", { name: new RegExp(name) })); + return screen.findByRole("dialog"); +} + +beforeEach(() => { + user = userEvent.setup({ delay: null }); + auth.userRoles = "Admin"; + serveTemplates(api, [BACKFLOW, SCRUBBER]); +}); + +describe("Completion Docs page", { timeout: 15_000 }, () => { + it("lists every template with its Document Title, Last Updated and Created By", async () => { + renderPage(); + + const backflow = await screen.findByRole("row", { name: /Backflow Test Report/ }); + const table = screen.getByRole("table", { name: "Completion document templates" }); + const headers = within(table) + .getAllByRole("columnheader") + .map((cell) => cell.textContent); + expect(headers.slice(0, 3)).toEqual(["Document Title", "Last Updated", "Created By"]); + + expect(within(backflow).getByText("Sep 25, 2026")).toBeInTheDocument(); + expect(within(backflow).getByText("Jane Doe")).toBeInTheDocument(); + const scrubber = within(table).getByRole("row", { name: /Scrubber Pit Augering/ }); + // Never updated: Last Updated falls back to the creation date; no creator shows a dash. + expect(within(scrubber).getByText("Jun 1, 2026")).toBeInTheDocument(); + expect(within(scrubber).getByText("—")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Completion Docs" })).toBeInTheDocument(); + expect(screen.getAllByText("2 templates")).toHaveLength(2); + }); + + it("filters by Document Title as the user types and explains an empty result", async () => { + renderPage(); + await screen.findByRole("row", { name: /Scrubber Pit Augering/ }); + const search = screen.getByRole("searchbox", { name: "Search document title" }); + + await user.type(search, "backf"); + expect( + await screen.findByText("1 template", { selector: "span" }, { timeout: 2000 }), + ).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Scrubber Pit Augering/ })).not.toBeInTheDocument(); + expect(api.apiGet).toHaveBeenCalledWith("workorders/completion-templates", { + searchParams: { search: "backf" }, + }); + + await user.clear(search); + await user.type(search, "no such document"); + expect( + await screen.findByText("No templates found", {}, { timeout: 2000 }), + ).toBeInTheDocument(); + expect(screen.getByText("Try a different search, or add a new template.")).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument(); + }); + + it("reports a failed load without the empty-search message, including after a failed Retry", async () => { + api.apiGet.mockRejectedValue(new Error("Templates are unavailable")); + renderPage(); + + const retry = await screen.findByRole("button", { name: "Retry" }); + expect(screen.getByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + + const callsBeforeRetry = api.apiGet.mock.calls.length; + await user.click(retry); + await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry)); + expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + }); + + it("opens a read-only detail panel from a row with every part of the template", async () => { + renderPage(); + const dialog = await openTemplate("Backflow Test Report"); + + expect(within(dialog).getByText("Document preview")).toBeInTheDocument(); + for (const field of ["WO#", "Site Code", "Address", "Service Type"]) { + expect(within(dialog).getByText(field)).toBeInTheDocument(); + } + const safety = within(dialog).getByRole("list", { name: "Safety notes" }); + expect(within(safety).getAllByRole("listitem")).toHaveLength(4); + expect(within(safety).getByText("Wear gloves")).toBeInTheDocument(); + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + expect( + within(dialog).getByText("Created by Jane Doe · Updated Sep 25, 2026"), + ).toBeInTheDocument(); + }); + + it("shows that a template without procedures has none defined yet", async () => { + renderPage(); + const dialog = await openTemplate("Scrubber Pit Augering"); + + expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument(); + expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument(); + }); + + it("gives an Admin every template action", async () => { + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + }); + + it("hides delete from a Scheduler, who may still add and edit", async () => { + auth.userRoles = "Scheduler"; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); + + it("hides add, edit and delete entirely from a user without those permissions", async () => { + auth.userRoles = "Dispatcher"; + serveTemplates(api, [templateDto()]); + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx new file mode 100644 index 00000000..87f0276b --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -0,0 +1,309 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HTTPError } from "ky"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; +import { renderWithProviders } from "@/test/test-utils"; +import { BACKFLOW, SCRUBBER, serveTemplates } from "./completion-docs-fixtures"; + +const api = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDeleteNoContent: vi.fn(), +})); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +const navigate = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); +vi.mock("react-toastify", () => ({ toast })); +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }), +})); + +// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down. +let user = userEvent.setup({ delay: null }); + +function renderPage() { + return renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + }); +} + +async function openNewTemplate() { + renderPage(); + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await user.click(screen.getByRole("button", { name: /Add Template/ })); + return screen.findByRole("dialog"); +} + +async function openTemplate(name: string) { + renderPage(); + await user.click(await screen.findByRole("row", { name: new RegExp(name) })); + return screen.findByRole("dialog"); +} + +function previewHeadings(dialog: HTMLElement) { + const preview = within(dialog).getByTestId("completion-template-preview"); + return within(preview) + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent); +} + +/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */ +function unprocessable(body: { code: string; message: string }, message = body.message) { + const error = new HTTPError( + new Response(null, { status: 422 }), + new Request("http://api/workorders/completion-templates"), + {} as never, + ); + error.data = body; + error.message = message; + return error; +} + +async function addProcedure(dialog: HTMLElement, index: number, name: string) { + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + await user.type(within(dialog).getByLabelText(`Procedure ${index} name`), name); +} + +beforeEach(() => { + user = userEvent.setup({ delay: null }); + vi.clearAllMocks(); + serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] }); +}); + +describe("Add Template", { timeout: 15_000 }, () => { + it("blocks saving without a Document Title and says why", async () => { + const dialog = await openNewTemplate(); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveFocus(); + expect(api.apiPost).not.toHaveBeenCalled(); + }); + + it("shows the fixed fields read-only and lets the author add then remove one safety note", async () => { + const dialog = await openNewTemplate(); + + const safety = within(dialog).getByRole("list", { name: "Safety notes" }); + expect(within(safety).getAllByRole("listitem")).toHaveLength(3); + expect(within(dialog).getByText("WO#")).toBeInTheDocument(); + expect(within(dialog).getAllByRole("textbox")).toHaveLength(1); + + await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" })); + expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull(); + await user.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit"); + expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent( + "Mind the pit", + ); + + await user.click(within(dialog).getByRole("button", { name: "Remove safety note" })); + expect(within(dialog).queryByLabelText("Extra safety note")).toBeNull(); + expect(within(dialog).getByTestId("completion-template-preview")).not.toHaveTextContent( + "Mind the pit", + ); + }); + + it("adds and removes procedures and previews them in editor order", async () => { + const dialog = await openNewTemplate(); + expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument(); + + await addProcedure(dialog, 1, "Isolate"); + await addProcedure(dialog, 2, "Inspect"); + await addProcedure(dialog, 3, "Restore"); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Inspect", "Restore"]); + + await user.click(within(dialog).getByRole("button", { name: "Remove procedure 2" })); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore"]); + expect(within(dialog).getByLabelText("Procedure 2 name")).toHaveValue("Restore"); + + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore", "Procedure 3"]); + }); + + it("saves the title, note and procedures in order, then confirms with a toast", async () => { + const dialog = await openNewTemplate(); + await user.type( + within(dialog).getByRole("textbox", { name: /Document Title/ }), + " PRV Check ", + ); + await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" })); + await user.type(within(dialog).getByLabelText("Extra safety note"), "Gloves"); + await addProcedure(dialog, 1, "Open"); + await user.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b"); + await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); + await addProcedure(dialog, 3, "Close"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template added")); + expect(api.apiPost).toHaveBeenCalledWith("workorders/completion-templates", { + name: "PRV Check", + extraSafetyNote: "Gloves", + procedures: [ + { name: "Open", description: "a\nb" }, + { name: "Close", description: "" }, + ], + }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("shows the server's blank-title rejection on the title field", async () => { + api.apiPost.mockRejectedValue( + unprocessable({ code: "TitleRequired", message: "Document Title is required." }), + ); + const dialog = await openNewTemplate(); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("reports any other rejected value in a toast and keeps the form open", async () => { + const message = "Procedure name must be 200 characters or fewer."; + api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message)); + const dialog = await openNewTemplate(); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); + + await user.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect(within(dialog).queryByText("Document Title is required")).toBeNull(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); +}); + +describe("Edit template", { timeout: 15_000 }, () => { + it("Cancel discards unsaved changes and returns to the original read-only values", async () => { + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + expect(within(dialog).getByText("Live preview")).toBeInTheDocument(); + const title = within(dialog).getByRole("textbox", { name: /Document Title/ }); + await user.clear(title); + await user.type(title, "Renamed"); + await user.click(within(dialog).getByRole("button", { name: "Remove procedure 1" })); + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + expect(within(dialog).getAllByText("Backflow Test Report").length).toBeGreaterThan(0); + expect(within(dialog).queryByText("Renamed")).not.toBeInTheDocument(); + expect(previewHeadings(dialog)).toEqual(["Backflow Procedure", "Shut-off"]); + expect(api.apiPut).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveValue( + "Backflow Test Report", + ); + }); + + it("saves an edit, keeps the legacy fields and confirms with a toast", async () => { + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2"); + + await user.click(within(dialog).getByRole("button", { name: /Save changes/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated")); + expect(api.apiPut).toHaveBeenCalledWith("workorders/completion-templates/12", { + serviceKey: "backflow", + workOrderType: 2, + templateUrl: "https://files.example/backflow.pdf", + isActive: true, + name: "Backflow Test Report v2", + extraSafetyNote: "Wear gloves", + procedures: [ + { name: "Backflow Procedure", description: "line 1\nline 2" }, + { name: "Shut-off", description: "Close valve" }, + ], + }); + expect( + await within(dialog).findByText("Created by Jane Doe · Updated Sep 25, 2026"), + ).toBeInTheDocument(); + }); + + it.each([ + [ + "Escape", + async () => { + // The disabled Save button drops focus; Escape is pressed from inside the form. + screen.getByRole("textbox", { name: /Document Title/ }).focus(); + await user.keyboard("{Escape}"); + }, + ], + ["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))], + ])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => { + let finishSave: () => void = () => {}; + const respond = api.apiPut.getMockImplementation()!; + api.apiPut.mockImplementation( + (...args: unknown[]) => + new Promise((resolve) => { + finishSave = () => resolve(respond(...args)); + }), + ); + const dialog = await openTemplate("Backflow Test Report"); + await user.click(within(dialog).getByRole("button", { name: /Edit/ })); + await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2"); + await user.click(within(dialog).getByRole("button", { name: /Save changes/ })); + await waitFor(() => expect(api.apiPut).toHaveBeenCalled()); + + await dismiss(); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + finishSave(); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); + +describe("Delete template", { timeout: 15_000 }, () => { + it("states how many open work orders depend on it and links to exactly those", async () => { + serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 2, workOrderIds: [101, 205] }); + const dialog = await openTemplate("Backflow Test Report"); + + await user.click(within(dialog).getByRole("button", { name: /Delete/ })); + + const confirm = await screen.findByRole("dialog", { name: "Delete this template?" }); + expect( + await within(confirm).findByText( + /It's still linked to 2 open work orders through its Service/, + ), + ).toBeInTheDocument(); + expect(api.apiDeleteNoContent).not.toHaveBeenCalled(); + await user.click(within(confirm).getByRole("button", { name: "View linked work orders" })); + expect(navigate).toHaveBeenCalledWith("/workorders?ids=101,205"); + }); + + it("still asks first when nothing depends on it, then deletes with a toast", async () => { + const dialog = await openTemplate("Scrubber Pit Augering"); + + await user.click(within(dialog).getByRole("button", { name: /Delete/ })); + + const confirm = await screen.findByRole("dialog", { name: "Delete this template?" }); + expect( + await within(confirm).findByText( + 'This will permanently remove "Scrubber Pit Augering". This cannot be undone.', + ), + ).toBeInTheDocument(); + expect(within(confirm).queryByRole("button", { name: "View linked work orders" })).toBeNull(); + + await user.click(within(confirm).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template deleted")); + expect(api.apiDeleteNoContent).toHaveBeenCalledWith("workorders/completion-templates/13"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); +}); diff --git a/src/test/app/(protected)/locations/site-delete.test.tsx b/src/test/app/(protected)/locations/site-delete.test.tsx new file mode 100644 index 00000000..10b2c1d1 --- /dev/null +++ b/src/test/app/(protected)/locations/site-delete.test.tsx @@ -0,0 +1,286 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { HTTPError } from "ky"; +import { Route, Routes, useLocation } from "react-router"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import { normalizeApiRequestError } from "@/api/api-error"; +import type { Location, LocationListItem } from "@/domain/locations/types/location"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; +import { renderWithProviders } from "@/test/test-utils"; + +const getList = vi.fn(); +const getById = vi.fn(); +const getOpenWorkOrders = vi.fn(); +const deleteSite = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args), + delete: (...args: unknown[]) => deleteSite(...args), + update: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const site: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }], +}; + +const row: LocationListItem = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + sitePhone: "", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "", + status: "Active", + accountId: 7, + contacts: site.contacts, +}; + +function WorkOrdersProbe() { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderAs(userRoles: string) { + const auth = { user: { userRoles } } as unknown as AuthContextValue; + return renderWithProviders( + + + } /> + } /> + + , + { withAuth: false, route: "/locations" }, + ); +} + +async function openPanel(): Promise { + fireEvent.click(await screen.findByRole("button", { name: "DAL1" })); + const panel = screen.getByRole("region", { name: "Site details" }); + await within(panel).findByRole("button", { name: "Edit" }); + return panel; +} + +function forbidden(): HTTPError { + const body = { status: "Error", message: "You are not allowed to delete sites." }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 403 }), + new Request("https://api.example.test/api/locations/1"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +describe("Delete a site from the detail panel", () => { + beforeEach(() => { + for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) { + mock.mockReset(); + } + getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 }); + getById.mockResolvedValue(site); + }); + + it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => { + renderAs(role); + const panel = await openPanel(); + + expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); + + it("hides Delete from a Dispatcher and removes the old row delete control", async () => { + renderAs("Dispatcher"); + const panel = await openPanel(); + + expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument(); + }); + + it("warns with the open work-order count and links to exactly those work orders", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] }); + renderAs("Scheduler"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect( + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.", + ), + ).toBeInTheDocument(); + expect(getOpenWorkOrders).toHaveBeenCalledWith("1"); + + fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" })); + + expect(await screen.findByTestId("work-orders-url")).toHaveTextContent( + "/workorders?ids=101,205,318", + ); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it.each([ + ["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }], + ["an open count with no ids", { count: 4, workOrderIds: [] }], + ])("links to the site's open work-order board when there are %s", async (_label, result) => { + getOpenWorkOrders.mockResolvedValue(result); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`)); + + fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" })); + + const url = new URL( + (await screen.findByTestId("work-orders-url")).textContent ?? "", + "https://app.test", + ); + expect(url.pathname).toBe("/workorders"); + expect(url.searchParams.get("ids")).toBeNull(); + expect(url.searchParams.get("sites")).toBe("1"); + expect(url.searchParams.get("statuses")?.split(",")).toEqual([ + "Incomplete", + "Pending", + "Scheduled", + "En Route", + "On Site", + "In Progress", + "Rescheduled", + "Pending Quote", + ]); + }); + + it("confirms without a warning when nothing is open, deletes and closes the panel", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] }); + deleteSite.mockResolvedValue(undefined); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect( + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. This cannot be undone.", + ), + ).toBeInTheDocument(); + expect( + within(dialog).queryByRole("button", { name: "View open work orders" }), + ).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1")); + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); + + it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => { + getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error")); + getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] }); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Couldn't check this site for open work orders. Try again before deleting it.", + ); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Try again" })); + + expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled(); + expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled(); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it("holds Delete on reopen until the fresh open work-order count replaces the cached one", async () => { + let resolveRecheck: (value: { count: number; workOrderIds: number[] }) => void = () => {}; + getOpenWorkOrders.mockResolvedValueOnce({ count: 0, workOrderIds: [] }); + getOpenWorkOrders.mockReturnValueOnce( + new Promise((resolve) => { + resolveRecheck = resolve; + }), + ); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + let dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText( + "This will permanently remove DAL1 from Sites. This cannot be undone.", + ); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Delete this site?" })).not.toBeInTheDocument(), + ); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await waitFor(() => expect(getOpenWorkOrders).toHaveBeenCalledTimes(2)); + + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument(); + expect(within(dialog).getByLabelText("Checking open work orders")).toBeInTheDocument(); + + resolveRecheck({ count: 1, workOrderIds: [101] }); + + expect(await within(dialog).findByText(/It still has 1 open work order —/)).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled(); + expect(deleteSite).not.toHaveBeenCalled(); + }); + + it("keeps the site and shows the server's refusal when the API answers 403", async () => { + getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] }); + deleteSite.mockRejectedValue(forbidden()); + const toastError = vi.spyOn(toast, "error"); + renderAs("Admin"); + const panel = await openPanel(); + + fireEvent.click(within(panel).getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog", { name: "Delete this site?" }); + await within(dialog).findByText(/This cannot be undone/); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."), + ); + expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument(); + expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx index 27e0edc4..8b5c2885 100644 --- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -235,17 +235,4 @@ describe("Sites page detail panel", () => { expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), ); }); - it("row delete asks to confirm removing the site without opening the panel", async () => { - renderWithProviders(, { withAuth: false }); - - fireEvent.click(await screen.findByRole("button", { name: "Delete site DAL1" })); - - const confirm = await screen.findByRole("dialog", { name: "Delete this site?" }); - expect( - within(confirm).getByText( - "This will permanently remove DAL1 from Sites. This cannot be undone.", - ), - ).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(); - }); }); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx index 3bbf9dc1..1816bb04 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); @@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeEnabled(); fireEvent.click(revoke); @@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeDisabled(); fireEvent.mouseOver(revoke); @@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument(); expect( screen.getByText("Uplifts you approve are archived here for reference."), diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx index 30a13a51..51c52285 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx @@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => { queueState.data = queueOf(approvedItem); await renderQueue(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(amountCell()).toHaveTextContent(UPLIFT); fireEvent.click(screen.getByRole("button", { name: "Revoke" })); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx new file mode 100644 index 00000000..23323d0a --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + UpliftQueueItem, + UpliftsQueueParams, + UpliftsQueueResult, +} from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +const queueState = vi.hoisted(() => ({ + byStatus: {} as Record, + errorByStatus: {} as Record, + requestedStatuses: [] as Array, +})); + +const baseItem: UpliftQueueItem = { + id: 1, + status: "Pending", + currentNTE: 500, + requestedNTE: 750, + delta: 250, + vendorReason: "Second hydro-jetting pass on the adjoining line", + requestedAt: "2026-06-21T10:05:00Z", + requestedByVendorName: "Alyssa Dispatcher", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-9", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 1024, + dispatchNumber: "DSP-1", + poNumber: "PO-1", + vendorCompanyName: "Drain Cleaning Bros", + workOrderId: 10, + dispatchId: 20, + woNumber: "WO-25603", + site: "CHI2", + serviceName: "Drains", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: 1, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +const newestRejection: UpliftQueueItem = { + ...baseItem, + id: 31, + status: "Rejected", + woNumber: "WO-31", + decidedAt: "2026-06-21T16:40:00Z", + decidedByName: "Adam Moussa", + decisionNote: "Outside this work order's scope — raise a separate reactive WO.", + canDecide: false, +}; + +const olderRejection: UpliftQueueItem = { + ...baseItem, + id: 30, + status: "Rejected", + woNumber: "WO-30", + requestedByVendorName: "Derek Dispatcher", + decidedAt: "2026-05-26T15:05:00Z", + decidedByName: "Grace Admin", + decisionNote: "Quote is above market for this scope.", + canDecide: false, +}; + +const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" }; +const approvedItem: UpliftQueueItem = { + ...baseItem, + id: 50, + status: "Approved", + woNumber: "WO-APPROVED", + decidedAt: "2026-06-01T09:00:00Z", + decidedByName: "Adam Moussa", +}; + +function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult { + return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null }; +} + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: (params: UpliftsQueueParams) => { + queueState.requestedStatuses.push(params.status); + const data = queueState.byStatus[params.status ?? ""]; + const error = queueState.errorByStatus[params.status ?? ""] ?? null; + return { + data, + isLoading: data === undefined && error === null, + isFetching: false, + error, + refetch: vi.fn(), + }; + }, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +async function openRejectedTab() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ })); + return screen.getByRole("table", { name: "rejected uplift requests" }); +} + +describe("Uplift Approvals rejected tab", () => { + // Load the page module once up front so the first test's budget is not spent on a + // cold import of the whole route. + beforeAll(async () => { + await import("@/app/(protected)/uplifts/index"); + }, 60_000); + + beforeEach(() => { + queueState.requestedStatuses = []; + queueState.errorByStatus = {}; + queueState.byStatus = { + Pending: page([pendingItem]), + Approved: page([approvedItem]), + Rejected: page([newestRejection, olderRejection], 12), + }; + }); + + it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ }); + expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument(); + expect(queueState.requestedStatuses).toContain("Rejected"); + }); + + it("never claims a count for a queue that is still loading or failed to load", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Forbidden"); + queueState.byStatus.Approved = undefined; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedPill = screen.getByLabelText("rejected uplift count unavailable"); + expect(rejectedPill).toHaveTextContent("—"); + expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—"); + expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument(); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + }); + + it("keeps the count and pager for kept rows when a rejected refetch fails", async () => { + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).getAllByRole("row")).toHaveLength(3); + expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByText(/of 12/)).toBeInTheDocument(); + }); + + it("shows the load error instead of an empty archive when the rejected queue fails", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument(); + expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument(); + expect(screen.queryByText(/of 0/)).not.toBeInTheDocument(); + }); + + it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => { + const table = await openRejectedTab(); + + const dataRows = within(table).getAllByRole("row").slice(1); + expect(dataRows).toHaveLength(2); + expect(dataRows[0]).toHaveTextContent("WO-31"); + expect(dataRows[1]).toHaveTextContent("WO-30"); + expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument(); + expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument(); + }); + + it("shows who requested, who rejected, when, and the rejection reason inline", async () => { + const table = await openRejectedTab(); + + for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) { + expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument(); + } + const [, firstRow] = within(table).getAllByRole("row"); + expect(firstRow).toHaveTextContent("Alyssa Dispatcher"); + expect(firstRow).toHaveTextContent("Adam Moussa"); + expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt)); + expect( + within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."), + ).toBeVisible(); + }); + + it("offers no action on a rejected uplift", async () => { + const table = await openRejectedTab(); + + expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument(); + for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) { + expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument(); + } + }); + + it("shows an icon and the empty copy when nothing has been rejected", async () => { + queueState.byStatus.Rejected = page([]); + const table = await openRejectedTab(); + + expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument(); + expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument(); + expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument(); + }); +}); diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts index 61184661..63ee8865 100644 --- a/src/test/config/menu.test.ts +++ b/src/test/config/menu.test.ts @@ -32,6 +32,15 @@ describe("navGroups", () => { expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services"); }); + it("lists Completion Docs after Services in the DATA MANAGEMENT group", () => { + const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT"); + expect(dataManagement?.items.map((item) => item.label)).toEqual([ + "Services", + "Completion Docs", + ]); + expect(getActiveNavPath("/completion-docs")).toBe("/completion-docs"); + }); + it("does not include the approvals group by default", () => { expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false); }); diff --git a/src/test/domain/completion-templates/completion-template-mapper.test.ts b/src/test/domain/completion-templates/completion-template-mapper.test.ts new file mode 100644 index 00000000..0b7209f7 --- /dev/null +++ b/src/test/domain/completion-templates/completion-template-mapper.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions"; +import { + mapCompletionTemplate, + toCompletionTemplateInput, +} from "@/domain/completion-templates/mappers/completion-template-mapper"; +import { completionTemplateDtoSchema } from "@/domain/completion-templates/schemas/completion-template-schema"; + +describe("mapCompletionTemplate", () => { + it("orders procedures by sortOrder and shows createdAt when never updated", () => { + const template = mapCompletionTemplate( + completionTemplateDtoSchema.parse({ + id: 4, + name: "PRV", + extraSafetyNote: " ", + procedures: [ + { id: 2, name: "Second", description: "b", sortOrder: 1 }, + { id: 1, name: "First", description: "a", sortOrder: 0 }, + ], + createdAt: "2026-09-01T00:00:00Z", + updatedAt: null, + createdByName: null, + }), + ); + + expect(template.procedures.map((procedure) => procedure.name)).toEqual(["First", "Second"]); + expect(template.lastUpdatedAt).toBe("2026-09-01T00:00:00Z"); + expect(template.extraSafetyNote).toBeNull(); + expect(template.createdByName).toBeNull(); + }); + + it("rejects a response that is not a template", () => { + expect(completionTemplateDtoSchema.safeParse({ name: "no id" }).success).toBe(false); + }); +}); + +describe("toCompletionTemplateInput", () => { + it("trims fields, clears a blank note and drops procedures left entirely blank", () => { + expect( + toCompletionTemplateInput({ + name: " Title ", + extraSafetyNote: " ", + procedures: [ + { name: " ", description: "" }, + { name: "", description: " steps " }, + ], + }), + ).toEqual({ + name: "Title", + extraSafetyNote: null, + procedures: [{ name: "", description: "steps" }], + }); + }); +}); + +describe("completionTemplatePermissions", () => { + it.each([ + ["Admin", { canCreate: true, canEdit: true, canDelete: true }], + ["Scheduler", { canCreate: true, canEdit: true, canDelete: false }], + ["Dispatcher", { canCreate: false, canEdit: false, canDelete: false }], + ["dispatcher, admin", { canCreate: true, canEdit: true, canDelete: true }], + ["Vendor", { canCreate: false, canEdit: false, canDelete: false }], + [undefined, { canCreate: false, canEdit: false, canDelete: false }], + ])("resolves %s to the role defaults", (roles, expected) => { + expect(completionTemplatePermissions(roles)).toEqual(expected); + }); +}); diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts index 62ce35d1..cd8734ce 100644 --- a/src/test/domain/locations/mappers/location-mapper.test.ts +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -3,6 +3,7 @@ import { mapLocation, mapLocationListItem, mapLocationToBackend, + mapSiteOpenWorkOrders, } from "@/domain/locations/mappers/location-mapper"; describe("location contacts hydration", () => { @@ -147,3 +148,16 @@ describe("mapLocation site fields", () => { expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" }); }); }); + +describe("mapSiteOpenWorkOrders", () => { + it("keeps the server count and drops invalid ids", () => { + expect(mapSiteOpenWorkOrders({ count: 240, workOrderIds: [101, "x", -3, 205] })).toEqual({ + count: 240, + workOrderIds: [101, 205], + }); + expect(mapSiteOpenWorkOrders({ Count: 0, WorkOrderIds: [] })).toEqual({ + count: 0, + workOrderIds: [], + }); + }); +}); diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx new file mode 100644 index 00000000..f71eb8d4 --- /dev/null +++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx @@ -0,0 +1,40 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error, success: vi.fn() }, +})); + +afterEach(() => { + vi.restoreAllMocks(); + toastMocks.error.mockReset(); +}); + +describe("useUpliftsQueue", () => { + it("does not show the global error toast, since the approvals page reports the failure inline", async () => { + vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue")); + // The real app query client runs the QueryCache's global onError handler, which + // toasts unless the query opts out. + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + + const { result } = renderHook( + () => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts index 7faab8b0..eea7afe8 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { + buildSiteOpenWorkOrdersHref, + parseDashboardDrilldownParams, +} from "@/domain/work-orders/utils/dashboard-drilldown-params"; import { avetaPendingDrilldownSearch, unassignedDrilldownSearch, @@ -102,6 +106,39 @@ describe("parseDashboardDrilldownParams", () => { expect(cleared.dispatchers.size).toBe(0); }); + it("turns the site open work-order link into that site's open, all-weeks board search", () => { + const href = buildSiteOpenWorkOrdersHref(12); + const filters = parseDashboardDrilldownParams(new URL(href, "https://app.test").searchParams); + + expect(filters?.sites).toEqual(new Set(["12"])); + expect(filters?.workOrderIds.size).toBe(0); + const params = buildResolvedBoardSearchParams({ + advApplied: filters, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [{ id: 12, name: "DAL1", siteCode: "DAL1" }], + vendors: [], + }); + expect(params).toMatchObject({ + sites: ["DAL1"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + }); + }); + + it("keeps an exact id set ahead of a site filter", () => { + const filters = parseDashboardDrilldownParams(new URLSearchParams({ ids: "4,9", sites: "12" })); + + expect(filters?.workOrderIds).toEqual(new Set([4, 9])); + expect(filters?.sites.size).toBe(0); + }); + it("rejects partial or malformed date ranges", () => { expect( parseDashboardDrilldownParams(